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
@@ -31,6 +31,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface AgentGroupDetailProps {
|
||||
group: AgentGroup;
|
||||
@@ -52,6 +53,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
group,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const selectedSessionId = useAgentGroupsStore((s) => s.selectedSessionId);
|
||||
const selectSession = useAgentGroupsStore((s) => s.selectSession);
|
||||
const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions);
|
||||
@@ -92,17 +94,17 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
|
||||
const handleCopyWorktreePath = React.useCallback(() => {
|
||||
if (!selectedSession?.path) {
|
||||
toast.error('No worktree path available');
|
||||
toast.error(t('agentManager.detail.toast.noWorktreePath'));
|
||||
return;
|
||||
}
|
||||
void copyTextToClipboard(selectedSession.path).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Worktree path copied');
|
||||
toast.success(t('agentManager.detail.toast.worktreePathCopied'));
|
||||
return;
|
||||
}
|
||||
toast.error('Failed to copy path');
|
||||
toast.error(t('agentManager.detail.toast.failedToCopyPath'));
|
||||
});
|
||||
}, [selectedSession?.path]);
|
||||
}, [selectedSession?.path, t]);
|
||||
|
||||
const handleRemoveSelectedWorktree = React.useCallback(() => {
|
||||
if (!selectedSession) return;
|
||||
@@ -123,24 +125,28 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
let sessionsToDelete: AgentGroupSession[];
|
||||
|
||||
if (worktreeDialog.kind === 'remove') {
|
||||
toast.info('Removing worktree...');
|
||||
toast.info(t('agentManager.detail.toast.removingWorktree'));
|
||||
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) === targetPath);
|
||||
} else {
|
||||
toast.info('Removing other worktrees...');
|
||||
toast.info(t('agentManager.detail.toast.removingOtherWorktrees'));
|
||||
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) !== targetPath);
|
||||
}
|
||||
|
||||
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(sessionsToDelete, { removeWorktrees: true });
|
||||
if (failedIds.length > 0 || failedWorktreePaths.length > 0) {
|
||||
toast.error('Failed to fully remove worktree');
|
||||
toast.error(t('agentManager.detail.toast.failedToFullyRemoveWorktree'));
|
||||
} else {
|
||||
toast.success(worktreeDialog.kind === 'remove' ? 'Worktree removed' : 'Removed other worktrees');
|
||||
toast.success(
|
||||
worktreeDialog.kind === 'remove'
|
||||
? t('agentManager.detail.toast.worktreeRemoved')
|
||||
: t('agentManager.detail.toast.otherWorktreesRemoved')
|
||||
);
|
||||
}
|
||||
setWorktreeDialog(null);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [deleteGroupSessions, group.sessions, isProcessing, worktreeDialog]);
|
||||
}, [deleteGroupSessions, group.sessions, isProcessing, t, worktreeDialog]);
|
||||
|
||||
// Group-level status: show if any session is busy
|
||||
const allStatuses = useAllSessionStatuses();
|
||||
@@ -160,11 +166,15 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
{groupBusy && <RiLoader4Line className="h-4 w-4 animate-spin text-amber-500 flex-shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 typography-meta text-muted-foreground">
|
||||
<span>{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}</span>
|
||||
<span>
|
||||
{group.sessionCount === 1
|
||||
? t('agentManager.detail.header.modelCountSingle', { count: group.sessionCount })
|
||||
: t('agentManager.detail.header.modelCountPlural', { count: group.sessionCount })}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<RiGitBranchLine className="h-3.5 w-3.5" />
|
||||
{selectedSession?.worktreeMetadata?.label || selectedSession?.branch || 'No branch'}
|
||||
{selectedSession?.worktreeMetadata?.label || selectedSession?.branch || t('agentManager.detail.header.noBranch')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,7 +253,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="flex-shrink-0" aria-label="Worktree actions">
|
||||
<Button variant="outline" size="icon" className="flex-shrink-0" aria-label={t('agentManager.detail.actions.worktreeActionsAria')}>
|
||||
<RiMore2Line className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -253,13 +263,13 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
closeOnClick={false}
|
||||
variant="destructive"
|
||||
>
|
||||
Remove this worktree
|
||||
{t('agentManager.detail.actions.removeThisWorktree')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleKeepOnlySelectedWorktree}
|
||||
closeOnClick={false}
|
||||
>
|
||||
Leave this one, remove others
|
||||
{t('agentManager.detail.actions.keepThisRemoveOthers')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -269,7 +279,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
disabled={!selectedSession?.path}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Copy Worktree Path
|
||||
{t('agentManager.detail.actions.copyWorktreePath')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -281,24 +291,33 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{worktreeDialog?.kind === 'remove' ? 'Remove worktree' : 'Remove other worktrees'}
|
||||
{worktreeDialog?.kind === 'remove'
|
||||
? t('agentManager.detail.dialog.removeWorktreeTitle')
|
||||
: t('agentManager.detail.dialog.removeOtherWorktreesTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{worktreeDialog?.kind === 'remove'
|
||||
? <>Remove <span className="text-foreground font-medium">{worktreeDialog?.label}</span>? This deletes all sessions in that worktree and removes the worktree itself.</>
|
||||
: <>Keep <span className="text-foreground font-medium">{worktreeDialog?.label}</span> and remove the other worktrees in <span className="text-foreground font-medium">{group.name}</span>.</>}
|
||||
? t('agentManager.detail.dialog.removeWorktreeDescription', { label: worktreeDialog?.label ?? '' })
|
||||
: t('agentManager.detail.dialog.removeOtherWorktreesDescription', {
|
||||
label: worktreeDialog?.label ?? '',
|
||||
group: group.name,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWorktreeDialog(null)} disabled={isProcessing}>
|
||||
Cancel
|
||||
{t('agentManager.detail.dialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={worktreeDialog?.kind === 'remove' ? 'destructive' : 'default'}
|
||||
onClick={() => void handleConfirmWorktreeAction()}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Working…' : worktreeDialog?.kind === 'remove' ? 'Remove' : 'Remove others'}
|
||||
{isProcessing
|
||||
? t('agentManager.detail.dialog.working')
|
||||
: worktreeDialog?.kind === 'remove'
|
||||
? t('agentManager.detail.dialog.remove')
|
||||
: t('agentManager.detail.dialog.removeOthers')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -328,10 +347,10 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<p className="typography-body text-muted-foreground mb-2">
|
||||
Loading session for <span className="font-medium text-foreground">{selectedSession.displayLabel}</span>
|
||||
{t('agentManager.detail.state.loadingSessionFor', { label: selectedSession.displayLabel })}
|
||||
</p>
|
||||
<p className="typography-micro text-muted-foreground/60">
|
||||
Session ID: {selectedSession.id}
|
||||
{t('agentManager.detail.state.sessionId', { id: selectedSession.id })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +359,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="typography-body text-muted-foreground">
|
||||
No sessions in this group
|
||||
{t('agentManager.detail.state.noSessionsInGroup')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
@@ -55,6 +56,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
onCreateGroup,
|
||||
isCreating = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [groupName, setGroupName] = React.useState('');
|
||||
const [prompt, setPrompt] = React.useState('');
|
||||
const [selectedModels, setSelectedModels] = React.useState<ModelSelectionWithId[]>([]);
|
||||
@@ -166,7 +168,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(`File "${file.name}" is too large (max 10MB)`);
|
||||
toast.error(t('agentManager.empty.toast.fileTooLarge', { fileName: file.name }));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -190,12 +192,16 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
attachedCount++;
|
||||
} catch (error) {
|
||||
console.error('File attach failed', error);
|
||||
toast.error(`Failed to attach "${file.name}"`);
|
||||
toast.error(t('agentManager.empty.toast.failedToAttach', { fileName: file.name }));
|
||||
}
|
||||
}
|
||||
|
||||
if (attachedCount > 0) {
|
||||
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||
toast.success(
|
||||
attachedCount === 1
|
||||
? t('agentManager.empty.toast.attachedSingle', { count: attachedCount })
|
||||
: t('agentManager.empty.toast.attachedPlural', { count: attachedCount })
|
||||
);
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
@@ -372,7 +378,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
setMentionQuery('');
|
||||
} catch (error) {
|
||||
console.error('Failed to create agent group:', error);
|
||||
toast.error('Failed to create agent group');
|
||||
toast.error(t('agentManager.empty.toast.failedToCreateGroup'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -415,17 +421,17 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
{/* Group Name Input */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="group-name" className="typography-ui-label font-medium text-foreground">
|
||||
Group Name
|
||||
{t('agentManager.empty.groupName.label')}
|
||||
</label>
|
||||
<Input
|
||||
id="group-name"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="e.g. feature-auth, bugfix-login"
|
||||
placeholder={t('agentManager.empty.groupName.placeholder')}
|
||||
className="typography-body"
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Used for worktree directory and branch naming
|
||||
{t('agentManager.empty.groupName.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -433,7 +439,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-1.5">
|
||||
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
|
||||
Base Branch
|
||||
{t('agentManager.empty.baseBranch.label')}
|
||||
</label>
|
||||
<BranchSelector
|
||||
directory={currentDirectory}
|
||||
@@ -441,7 +447,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
onChange={setBaseBranch}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Creates new branches from <code className="font-mono text-xs">{baseBranch}</code>
|
||||
{t('agentManager.empty.baseBranch.description', { branch: baseBranch })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -449,10 +455,10 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:bg-[var(--interactive-hover)] rounded-md px-1 -mx-1 transition-colors">
|
||||
<p className="typography-ui-label font-medium text-foreground">
|
||||
Setup commands
|
||||
{t('agentManager.empty.setupCommands.label')}
|
||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
||||
<span className="font-normal text-muted-foreground/70">
|
||||
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
|
||||
{' '}({t('agentManager.empty.setupCommands.configured', { count: setupCommands.filter(cmd => cmd.trim()).length })})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -464,10 +470,10 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
|
||||
{t('agentManager.empty.setupCommands.description')}
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('agentManager.empty.setupCommands.loading')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{setupCommands.map((command, index) => (
|
||||
@@ -479,7 +485,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
newCommands[index] = e.target.value;
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
placeholder="e.g., bun install"
|
||||
placeholder={t('agentManager.empty.setupCommands.commandPlaceholder')}
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
@@ -489,7 +495,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
aria-label={t('agentManager.empty.setupCommands.removeCommandAria')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -501,7 +507,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
{t('agentManager.empty.setupCommands.addCommand')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -512,21 +518,21 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
{/* Agent Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Agent
|
||||
{t('agentManager.empty.agent.label')}
|
||||
</label>
|
||||
<AgentSelector
|
||||
value={selectedAgent}
|
||||
onChange={setSelectedAgent}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Defaults to your configured default agent
|
||||
{t('agentManager.empty.agent.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Models
|
||||
{t('agentManager.empty.models.label')}
|
||||
</label>
|
||||
<ModelMultiSelect
|
||||
selectedModels={selectedModels}
|
||||
@@ -534,7 +540,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
onRemove={handleRemoveModel}
|
||||
onUpdate={handleUpdateModel}
|
||||
minModels={1}
|
||||
addButtonLabel="Add model"
|
||||
addButtonLabel={t('agentManager.empty.models.addModel')}
|
||||
maxModels={5}
|
||||
/>
|
||||
</div>
|
||||
@@ -542,7 +548,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
{/* Chat Input Style Prompt */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="prompt" className="typography-ui-label font-medium text-foreground">
|
||||
Prompt
|
||||
{t('agentManager.empty.prompt.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div
|
||||
@@ -561,7 +567,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
updateAutocompleteState(nextPrompt, cursorPosition);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
placeholder={t('agentManager.empty.prompt.placeholder')}
|
||||
className="min-h-[100px] max-h-[300px] resize-none border-0 bg-transparent dark:bg-transparent px-4 py-3 typography-markdown focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
|
||||
@@ -609,7 +615,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Add attachment"
|
||||
aria-label={t('agentManager.empty.prompt.addAttachmentAria')}
|
||||
>
|
||||
<RiAddCircleLine className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
@@ -618,7 +624,9 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
{/* Right Controls - Model Count */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{selectedModels.length} model{selectedModels.length !== 1 ? 's' : ''} selected
|
||||
{selectedModels.length === 1
|
||||
? t('agentManager.empty.models.selectedSingle', { count: selectedModels.length })
|
||||
: t('agentManager.empty.models.selectedPlural', { count: selectedModels.length })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Submit Button */}
|
||||
@@ -631,7 +639,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
? 'text-primary hover:text-primary'
|
||||
: 'opacity-30'
|
||||
)}
|
||||
aria-label="Start Agent Group"
|
||||
aria-label={t('agentManager.empty.actions.startAgentGroupAria')}
|
||||
>
|
||||
{isSubmittingOrCreating ? (
|
||||
<RiHourglassFill className="h-[18px] w-[18px] animate-spin" />
|
||||
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
|
||||
import { useAllSessionStatuses } from '@/sync/sync-context';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const formatRelativeTime = (timestamp: number): { unit: 'now' | 'minutes' | 'hours' | 'days'; count?: number } => {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
@@ -37,10 +38,10 @@ const formatRelativeTime = (timestamp: number): string => {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
|
||||
|
||||
if (minutes < 1) return 'now';
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${days}d`;
|
||||
if (minutes < 1) return { unit: 'now' };
|
||||
if (minutes < 60) return { unit: 'minutes', count: minutes };
|
||||
if (hours < 24) return { unit: 'hours', count: hours };
|
||||
return { unit: 'days', count: days };
|
||||
};
|
||||
|
||||
interface AgentGroupItemProps {
|
||||
@@ -51,6 +52,7 @@ interface AgentGroupItemProps {
|
||||
}
|
||||
|
||||
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBusy, onSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
@@ -59,16 +61,18 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
|
||||
const handleDeleteGroup = React.useCallback(async () => {
|
||||
if (isDeleting) return;
|
||||
setIsDeleting(true);
|
||||
toast.info(`Deleting "${group.name}"...`);
|
||||
toast.info(t('agentManager.sidebar.toast.deletingGroup', { group: group.name }));
|
||||
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(group.sessions, { removeWorktrees: true });
|
||||
if (failedIds.length === 0 && failedWorktreePaths.length === 0) {
|
||||
toast.success(`Deleted "${group.name}"`);
|
||||
toast.success(t('agentManager.sidebar.toast.deletedGroup', { group: group.name }));
|
||||
} else {
|
||||
toast.error(`Failed to fully delete "${group.name}"`);
|
||||
toast.error(t('agentManager.sidebar.toast.failedToDeleteGroup', { group: group.name }));
|
||||
}
|
||||
setIsDeleting(false);
|
||||
setConfirmOpen(false);
|
||||
}, [deleteGroupSessions, group.name, group.sessions, isDeleting]);
|
||||
}, [deleteGroupSessions, group.name, group.sessions, isDeleting, t]);
|
||||
|
||||
const relativeTime = formatRelativeTime(group.lastActive);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -91,12 +95,20 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
|
||||
{isBusy && <RiLoader4Line className="h-3 w-3 animate-spin text-amber-500 flex-shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
{group.sessionCount === 1
|
||||
? t('agentManager.sidebar.item.modelCountSingle', { count: group.sessionCount })
|
||||
: t('agentManager.sidebar.item.modelCountPlural', { count: group.sessionCount })}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/60">
|
||||
{formatRelativeTime(group.lastActive)}
|
||||
{relativeTime.unit === 'now'
|
||||
? t('agentManager.sidebar.relativeTime.now')
|
||||
: relativeTime.unit === 'minutes'
|
||||
? t('agentManager.sidebar.relativeTime.minutes', { count: relativeTime.count ?? 0 })
|
||||
: relativeTime.unit === 'hours'
|
||||
? t('agentManager.sidebar.relativeTime.hours', { count: relativeTime.count ?? 0 })
|
||||
: t('agentManager.sidebar.relativeTime.days', { count: relativeTime.count ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -111,7 +123,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
menuOpen && 'opacity-100',
|
||||
)}
|
||||
aria-label="Group menu"
|
||||
aria-label={t('agentManager.sidebar.item.groupMenuAria')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
@@ -126,7 +138,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{t('agentManager.sidebar.item.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -137,17 +149,17 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete agent group</DialogTitle>
|
||||
<DialogTitle>{t('agentManager.sidebar.dialog.deleteGroupTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Delete <span className="text-foreground font-medium">{group.name}</span>? This removes all worktrees and sessions in this group.
|
||||
{t('agentManager.sidebar.dialog.deleteGroupDescription', { group: group.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)} disabled={isDeleting}>
|
||||
Cancel
|
||||
{t('agentManager.sidebar.dialog.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleDeleteGroup()} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
{isDeleting ? t('agentManager.sidebar.dialog.deleting') : t('agentManager.sidebar.dialog.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -171,6 +183,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
onGroupSelect,
|
||||
onNewAgent,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [showAll, setShowAll] = React.useState(false);
|
||||
const isLoading = useAgentGroupsStore((s) => s.isLoading);
|
||||
@@ -209,7 +222,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search Agent Groups..."
|
||||
placeholder={t('agentManager.sidebar.search.placeholder')}
|
||||
className="pl-8 h-8 rounded-lg border-border/40 bg-background/50 typography-meta"
|
||||
/>
|
||||
</div>
|
||||
@@ -223,7 +236,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
onClick={onNewAgent}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label">New Agent Group</span>
|
||||
<span className="typography-ui-label">{t('agentManager.sidebar.actions.newAgentGroup')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -231,11 +244,11 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
<div className="px-2.5 py-1.5 flex items-center gap-1">
|
||||
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="typography-micro font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agent Groups
|
||||
{t('agentManager.sidebar.section.agentGroups')}
|
||||
</span>
|
||||
{isLoading && (
|
||||
<span className="typography-micro text-muted-foreground/50 ml-auto">
|
||||
Loading...
|
||||
{t('agentManager.sidebar.state.loading')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -261,7 +274,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mt-1 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left typography-micro text-muted-foreground/70 hover:text-foreground hover:underline"
|
||||
>
|
||||
... More ({remainingCount})
|
||||
{t('agentManager.sidebar.actions.more', { count: remainingCount })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -271,18 +284,18 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
onClick={() => setShowAll(false)}
|
||||
className="mt-1 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left typography-micro text-muted-foreground/70 hover:text-foreground hover:underline"
|
||||
>
|
||||
Show less
|
||||
{t('agentManager.sidebar.actions.showLess')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredGroups.length === 0 && (
|
||||
<div className="py-4 text-center">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{searchQuery.trim() ? 'No groups found' : 'No agent groups yet'}
|
||||
{searchQuery.trim() ? t('agentManager.sidebar.state.noGroupsFound') : t('agentManager.sidebar.state.noGroupsYet')}
|
||||
</p>
|
||||
{!searchQuery.trim() && (
|
||||
<p className="typography-micro text-muted-foreground/60 mt-1">
|
||||
Create a new agent group to get started
|
||||
{t('agentManager.sidebar.state.createToGetStarted')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user