Add i18n foundation and translations (#1027)

* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

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

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

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

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
@@ -28,6 +28,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { sessionEvents } from '@/lib/sessionEvents';
import { useSessions } from '@/sync/sync-context';
import { useI18n } from '@/lib/i18n';
export interface BranchPickerProject {
id: string;
@@ -65,6 +66,7 @@ const normalizePath = (value: string | null | undefined): string => {
};
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
const { t } = useI18n();
const sessions = useSessions();
const [searchQuery, setSearchQuery] = React.useState('');
const [branches, setBranches] = React.useState<GitBranch | null>(null);
@@ -95,14 +97,14 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
setWorktrees(w);
setRootBranchName(rootBranch);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setError(err instanceof Error ? err.message : t('branchPickerDialog.error.failedToLoad'));
setBranches(null);
setWorktrees([]);
setRootBranchName(null);
} finally {
setLoading(false);
}
}, [project]);
}, [project, t]);
React.useEffect(() => {
if (!open) {
@@ -152,18 +154,18 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
try {
const result = await renameBranch(project.path, oldName, newName);
if (!result?.success) {
throw new Error('Rename rejected');
throw new Error(t('branchPickerDialog.error.renameRejected'));
}
await refresh();
cancelRename();
toast.success('Branch renamed', { description: `${oldName} -> ${newName}` });
toast.success(t('branchPickerDialog.toast.branchRenamed'), { description: `${oldName} -> ${newName}` });
} catch (err) {
toast.error('Failed to rename branch', {
description: err instanceof Error ? err.message : 'Rename failed',
toast.error(t('branchPickerDialog.toast.failedToRenameBranch'), {
description: err instanceof Error ? err.message : t('branchPickerDialog.error.renameFailed'),
});
setRenamingBranchKey(null);
}
}, [project, editValue, refresh, cancelRename]);
}, [project, editValue, refresh, cancelRename, t]);
const handleDeleteBranch = React.useCallback(async (branchName: string) => {
if (!project) return;
@@ -172,27 +174,27 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
const force = forceDeleteBranch === branchName;
const result = await deleteGitBranch(project.path, { branch: branchName, force });
if (!result?.success) {
throw new Error('Delete rejected');
throw new Error(t('branchPickerDialog.error.deleteRejected'));
}
await refresh();
toast.success('Branch deleted', { description: branchName });
toast.success(t('branchPickerDialog.toast.branchDeleted'), { description: branchName });
setConfirmingDelete(null);
setForceDeleteBranch(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Delete failed';
const message = err instanceof Error ? err.message : t('branchPickerDialog.error.deleteFailed');
// If branch isn't merged, prompt for force delete on next confirm.
if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) {
setForceDeleteBranch(branchName);
toast.error('Branch not merged', {
description: 'Confirm again to force delete',
toast.error(t('branchPickerDialog.toast.branchNotMerged'), {
description: t('branchPickerDialog.toast.confirmAgainToForceDelete'),
});
} else {
toast.error('Failed to delete branch', { description: message });
toast.error(t('branchPickerDialog.toast.failedToDeleteBranch'), { description: message });
}
} finally {
setDeletingBranch(null);
}
}, [project, refresh, forceDeleteBranch]);
}, [project, refresh, forceDeleteBranch, t]);
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
if (!project) {
@@ -220,15 +222,15 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
}
);
await refresh();
toast.success('Worktree created', { description: branchName });
toast.success(t('branchPickerDialog.toast.worktreeCreated'), { description: branchName });
} catch (err) {
toast.error('Failed to create worktree', {
description: err instanceof Error ? err.message : 'Create worktree failed',
toast.error(t('branchPickerDialog.toast.failedToCreateWorktree'), {
description: err instanceof Error ? err.message : t('branchPickerDialog.error.createWorktreeFailed'),
});
} finally {
setCreatingWorktreeBranch(null);
}
}, [project, refresh]);
}, [project, refresh, t]);
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
if (!project || !worktree) {
@@ -303,17 +305,17 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGitBranchLine className="h-5 w-5" />
Manage Branches
{t('branchPickerDialog.title')}
</DialogTitle>
<DialogDescription>
{project ? `Local branches for ${displayProjectName(project)}` : 'Select a project'}
{project ? t('branchPickerDialog.description.localBranchesForProject', { project: displayProjectName(project) }) : t('branchPickerDialog.description.selectProject')}
</DialogDescription>
</DialogHeader>
<div className="relative flex-shrink-0">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search branches..."
placeholder={t('branchPickerDialog.search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -323,14 +325,14 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1">
{!project ? (
<div className="text-center py-8 text-muted-foreground">No project selected</div>
<div className="text-center py-8 text-muted-foreground">{t('branchPickerDialog.state.noProjectSelected')}</div>
) : loading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">Loading branches...</div>
<div className="px-2 py-2 text-muted-foreground text-sm">{t('branchPickerDialog.state.loadingBranches')}</div>
) : error ? (
<div className="px-2 py-2 text-destructive text-sm">{error}</div>
) : localBranches.length === 0 ? (
<div className="px-2 py-2 text-muted-foreground text-sm">
{searchQuery ? 'No matching branches' : 'No branches found'}
{searchQuery ? t('branchPickerDialog.state.noMatchingBranches') : t('branchPickerDialog.state.noBranchesFound')}
</div>
) : (
localBranches.map((branchName) => {
@@ -386,7 +388,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
onChange={(event) => setEditValue(event.target.value)}
className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Rename branch"
placeholder={t('branchPickerDialog.search.renameBranchPlaceholder')}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -407,13 +409,13 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
{isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
HEAD
{t('branchPickerDialog.badge.head')}
</span>
)}
{hasAttachedWorktree && !isEditing && (
<span className="text-xs bg-muted/40 text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
worktree
{t('branchPickerDialog.badge.worktree')}
</span>
)}
</div>
@@ -440,7 +442,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
onClick={() => void handleCreateWorktreeForBranch(branchName)}
disabled={disableCreateWorktree}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Create worktree"
aria-label={t('branchPickerDialog.actions.createWorktreeAria')}
>
{isCreatingWorktree ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -450,7 +452,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
</button>
</TooltipTrigger>
<TooltipContent side="left">
{hasAttachedWorktree ? 'Worktree already exists' : 'Create worktree'}
{hasAttachedWorktree ? t('branchPickerDialog.tooltip.worktreeAlreadyExists') : t('branchPickerDialog.tooltip.createWorktree')}
</TooltipContent>
</Tooltip>
@@ -461,13 +463,13 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
onClick={() => beginRename(branchName)}
disabled={disableRename}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Rename"
aria-label={t('branchPickerDialog.actions.renameAria')}
>
<RiPencilLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{isProjectRootBranch ? 'Rename disabled for root branch' : 'Rename'}
{isProjectRootBranch ? t('branchPickerDialog.tooltip.renameDisabledForRoot') : t('branchPickerDialog.tooltip.rename')}
</TooltipContent>
</Tooltip>
@@ -484,7 +486,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
}}
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label={hasAttachedWorktree ? 'Delete worktree' : 'Delete'}
aria-label={hasAttachedWorktree ? t('branchPickerDialog.actions.deleteWorktreeAria') : t('branchPickerDialog.actions.deleteAria')}
>
{isDeleting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -496,13 +498,13 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
<TooltipContent side="left">
{hasAttachedWorktree
? isProjectRootBranch
? 'Delete worktree (root branch protected)'
: 'Delete worktree'
? t('branchPickerDialog.tooltip.deleteWorktreeRootProtected')
: t('branchPickerDialog.tooltip.deleteWorktree')
: isCurrent
? 'Delete (current branch)'
? t('branchPickerDialog.tooltip.deleteCurrentBranch')
: isProjectRootBranch
? 'Delete disabled for root branch'
: 'Delete'}
? t('branchPickerDialog.tooltip.deleteDisabledForRoot')
: t('branchPickerDialog.tooltip.delete')}
</TooltipContent>
</Tooltip>
</div>
@@ -515,7 +517,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
onClick={() => void commitRename(branchName)}
disabled={isRenaming}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Confirm rename"
aria-label={t('branchPickerDialog.actions.confirmRenameAria')}
>
{isRenaming ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -527,7 +529,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
type="button"
onClick={cancelRename}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel rename"
aria-label={t('branchPickerDialog.actions.cancelRenameAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -540,7 +542,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
'text-xs mr-1',
isForceDelete ? 'text-destructive' : 'text-muted-foreground'
)}>
{isForceDelete ? 'Force delete?' : 'Delete?'}
{isForceDelete ? t('branchPickerDialog.actions.forceDeletePrompt') : t('branchPickerDialog.actions.deletePrompt')}
</span>
<button
type="button"
@@ -552,7 +554,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
? 'bg-destructive/10 text-destructive hover:bg-destructive/15'
: 'hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
)}
aria-label="Confirm delete"
aria-label={t('branchPickerDialog.actions.confirmDeleteAria')}
>
{isDeleting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -564,7 +566,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
type="button"
onClick={cancelDelete}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel delete"
aria-label={t('branchPickerDialog.actions.cancelDeleteAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -26,6 +26,7 @@ import {
setDirectoryShowHidden,
useDirectoryShowHidden,
} from '@/lib/directoryShowHidden';
import { useI18n } from '@/lib/i18n';
interface DirectoryExplorerDialogProps {
open: boolean;
@@ -36,6 +37,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
open,
onOpenChange,
}) => {
const { t } = useI18n();
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { addProject, getActiveProject } = useProjectsStore();
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
@@ -97,8 +99,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
if (isDesktop) {
const accessResult = await requestAccess(targetPath);
if (!accessResult.success) {
toast.error('Unable to access directory', {
description: accessResult.error || 'Desktop denied directory access.',
toast.error(t('directoryExplorerDialog.toast.unableToAccessDirectory'), {
description: accessResult.error || t('directoryExplorerDialog.toast.desktopDeniedAccess'),
});
return;
}
@@ -107,8 +109,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const startResult = await startAccessing(resolvedPath);
if (!startResult.success) {
toast.error('Failed to open directory', {
description: startResult.error || 'Desktop could not grant file access.',
toast.error(t('directoryExplorerDialog.toast.failedToOpenDirectory'), {
description: startResult.error || t('directoryExplorerDialog.toast.desktopCouldNotGrantAccess'),
});
return;
}
@@ -116,16 +118,16 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const added = addProject(resolvedPath, { id: projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory path.',
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
});
return;
}
handleClose();
} catch (error) {
toast.error('Failed to select directory', {
description: error instanceof Error ? error.message : 'Unknown error occurred.',
toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), {
description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'),
});
} finally {
setIsConfirming(false);
@@ -137,6 +139,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
requestAccess,
startAccessing,
isConfirming,
t,
]);
const handleConfirm = React.useCallback(async () => {
@@ -215,16 +218,16 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
) : (
<RiCheckboxBlankLine className="h-4 w-4" />
)}
Show hidden
{t('directoryExplorerDialog.toggle.showHidden')}
</button>
);
const dialogHeader = (
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
<DialogTitle>Add project directory</DialogTitle>
<DialogTitle>{t('directoryExplorerDialog.title')}</DialogTitle>
<div className="hidden sm:flex sm:items-center sm:justify-between sm:gap-4">
<DialogDescription className="flex-1">
Choose a folder to add as a project.
{t('directoryExplorerDialog.description')}
</DialogDescription>
{showHiddenToggle}
</div>
@@ -237,7 +240,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
value={pathInputValue}
onChange={handlePathInputChange}
onKeyDown={handlePathInputKeyDown}
placeholder="Enter path or select from tree..."
placeholder={t('directoryExplorerDialog.pathInput.placeholder')}
className="font-mono typography-meta"
spellCheck={false}
autoComplete="off"
@@ -311,14 +314,14 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
disabled={isConfirming}
className="flex-1 sm:flex-none sm:w-auto"
>
Cancel
{t('directoryExplorerDialog.actions.cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())}
className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]"
>
{isConfirming ? 'Adding...' : 'Add Project'}
{isConfirming ? t('directoryExplorerDialog.actions.adding') : t('directoryExplorerDialog.actions.addProject')}
</Button>
</>
);
@@ -328,7 +331,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
<MobileOverlayPanel
open={open}
onClose={() => onOpenChange(false)}
title="Add project directory"
title={t('directoryExplorerDialog.title')}
className="max-w-full"
contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]"
footer={<div className="flex flex-row gap-2">{renderActionButtons()}</div>}
@@ -16,6 +16,7 @@ import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
interface DirectoryItem {
name: string;
@@ -53,6 +54,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
isRootReady,
alwaysShowActions = false,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
const [directories, setDirectories] = React.useState<DirectoryItem[]>([]);
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(new Set());
@@ -678,7 +680,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
isMobile ? "p-1.5" : "p-1",
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
)}
title="Create new directory"
title={t('directoryTree.actions.createNewDirectory')}
>
<RiAddLine className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
</button>
@@ -693,7 +695,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
isMobile ? "p-1.5" : "p-1",
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
)}
title={isPinned ? "Unpin directory" : "Pin directory"}
title={isPinned ? t('directoryTree.actions.unpinDirectory') : t('directoryTree.actions.pinDirectory')}
>
{isPinned ? (
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
@@ -745,7 +747,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
}}
onBlur={createDirectory}
className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground"
placeholder="new_directory"
placeholder={t('directoryTree.field.newDirectoryPlaceholder')}
/>
<button
onClick={(e) => {
@@ -754,7 +756,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
createDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Create directory"
title={t('directoryTree.actions.createDirectory')}
>
<RiCheckLine className="h-3 w-3 text-green-600" />
</button>
@@ -765,7 +767,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
cancelCreatingDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Cancel"
title={t('directoryTree.actions.cancel')}
>
<RiCloseLine className="h-3 w-3 text-muted-foreground" />
</button>
@@ -834,7 +836,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
}}
onBlur={createDirectory}
className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground"
placeholder="new_directory"
placeholder={t('directoryTree.field.newDirectoryPlaceholder')}
/>
<button
onClick={(e) => {
@@ -843,7 +845,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
createDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Create directory"
title={t('directoryTree.actions.createDirectory')}
>
<RiCheckLine className="h-3 w-3 text-green-600" />
</button>
@@ -854,7 +856,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
cancelCreatingDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Cancel"
title={t('directoryTree.actions.cancel')}
>
<RiCloseLine className="h-3 w-3 text-muted-foreground" />
</button>
@@ -919,7 +921,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
"hover:bg-interactive-hover rounded-md transition-opacity",
isMobile ? "p-1.5 opacity-60" : "p-1 opacity-0 group-hover:opacity-100"
)}
title="Unpin directory"
title={t('directoryTree.actions.unpinDirectory')}
>
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
</button>
@@ -956,7 +958,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
togglePin(path);
}}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-interactive-hover rounded transition-opacity"
title="Unpin directory"
title={t('directoryTree.actions.unpinDirectory')}
>
<RiPushpin2Line className="h-3 w-3 text-primary" />
</button>
@@ -968,7 +970,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
<>
{!rootReady ? (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
Locating home directory...
{t('directoryTree.state.locatingHomeDirectory')}
</div>
) : (
<>
@@ -987,7 +989,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
) : (
<RiArrowRightSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
)}
<span>Pinned</span>
<span>{t('directoryTree.section.pinned')}</span>
<span className="ml-auto typography-micro text-muted-foreground/60 normal-case tracking-normal">
{pinnedDirectories.length}
</span>
@@ -1004,12 +1006,12 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
"typography-meta font-medium text-muted-foreground/80 flex items-center gap-1.5 uppercase tracking-wide",
isMobile ? "px-1.5 py-1" : "px-2 py-1.5"
)}>
Browse
{t('directoryTree.section.browse')}
</div>
{isLoading ? (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
Loading...
{t('directoryTree.state.loading')}
</div>
) : (
directories.map((item) => renderTreeItem(item))
@@ -1017,7 +1019,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
{!isLoading && directories.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No directories found
{t('directoryTree.state.noDirectoriesFound')}
</div>
)}
</>
@@ -1044,7 +1046,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
'w-full h-8 px-2.5 justify-between items-center rounded-lg border border-transparent bg-sidebar-accent/40 text-foreground/90 hover:bg-sidebar-accent/60 typography-meta',
triggerClassName
)}
aria-label="Select working directory"
aria-label={t('directoryTree.actions.selectWorkingDirectoryAria')}
>
<span className="flex items-center gap-1.5 min-w-0 flex-1">
<RiFolder6Line className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
@@ -33,6 +33,7 @@ import type {
GitHubPullRequestSummary,
} from '@/lib/api/types';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { useI18n } from '@/lib/i18n';
type GitHubTab = 'issues' | 'prs';
@@ -56,6 +57,7 @@ export function GitHubIntegrationDialog({
onOpenChange,
onSelect,
}: GitHubIntegrationDialogProps) {
const { t } = useI18n();
const isMobile = useUIStore((state) => state.isMobile);
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
@@ -101,7 +103,7 @@ export function GitHubIntegrationDialog({
if (activeTab === 'issues' && github.issuesList) {
const result = await github.issuesList(projectDirectory, { page: 1 });
if (result.connected === false) {
setError('GitHub not connected');
setError(t('session.githubIntegration.error.notConnected'));
setIssues([]);
} else {
setIssues(result.issues ?? []);
@@ -111,7 +113,7 @@ export function GitHubIntegrationDialog({
} else if (activeTab === 'prs' && github.prsList) {
const result = await github.prsList(projectDirectory, { page: 1 });
if (result.connected === false) {
setError('GitHub not connected');
setError(t('session.githubIntegration.error.notConnected'));
setPrs([]);
} else {
setPrs(result.prs ?? []);
@@ -120,11 +122,11 @@ export function GitHubIntegrationDialog({
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
setError(err instanceof Error ? err.message : t('session.githubIntegration.error.loadDataFailed'));
} finally {
setLoading(false);
}
}, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab]);
}, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, t]);
// Load more data
const loadMore = React.useCallback(async () => {
@@ -199,15 +201,15 @@ export function GitHubIntegrationDialog({
setValidations(prev => new Map(prev).set(branchName, {
isValid: !isBlocked,
error: isBlocked ? 'Branch is already checked out in a worktree' : null,
error: isBlocked ? t('session.githubIntegration.validation.branchAlreadyCheckedOut') : null,
}));
} catch {
setValidations(prev => new Map(prev).set(branchName, {
isValid: false,
error: 'Validation failed',
error: t('session.githubIntegration.validation.failed'),
}));
}
}, [projectRef, validations]);
}, [projectRef, validations, t]);
// Validate PR branches when loaded
React.useEffect(() => {
@@ -297,12 +299,12 @@ export function GitHubIntegrationDialog({
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-4">
<RiGithubLine className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="typography-ui-label text-foreground">Connect to GitHub</p>
<p className="typography-ui-label text-foreground">{t('session.githubIntegration.connect.title')}</p>
<p className="typography-small text-muted-foreground mt-1">
Link issues or pull requests to auto-fill worktree details
{t('session.githubIntegration.connect.description')}
</p>
</div>
<Button onClick={openGitHubSettings} size="sm">Connect GitHub</Button>
<Button onClick={openGitHubSettings} size="sm">{t('session.githubIntegration.connect.action')}</Button>
</div>
) : (
<>
@@ -312,7 +314,9 @@ export function GitHubIntegrationDialog({
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={activeTab === 'issues' ? "Search issues or enter #123..." : "Search PRs or enter #456..."}
placeholder={activeTab === 'issues'
? t('session.githubIntegration.search.issuesPlaceholder')
: t('session.githubIntegration.search.prsPlaceholder')}
className="h-8 pl-9"
/>
</div>
@@ -360,7 +364,7 @@ export function GitHubIntegrationDialog({
))
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
No issues found
{t('session.githubIntegration.empty.noIssuesFound')}
</div>
)}
@@ -372,7 +376,7 @@ export function GitHubIntegrationDialog({
onClick={() => void loadMore()}
className="h-7 text-xs"
>
Load more
{t('session.githubIntegration.actions.loadMore')}
</Button>
</div>
)}
@@ -427,7 +431,7 @@ export function GitHubIntegrationDialog({
})
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
No pull requests found
{t('session.githubIntegration.empty.noPullRequestsFound')}
</div>
)}
@@ -439,7 +443,7 @@ export function GitHubIntegrationDialog({
onClick={() => void loadMore()}
className="h-7 text-xs"
>
Load more
{t('session.githubIntegration.actions.loadMore')}
</Button>
</div>
)}
@@ -473,7 +477,9 @@ export function GitHubIntegrationDialog({
<div className="flex items-center gap-2 px-2 h-8 rounded-md bg-muted/50 border border-border/50">
<RiCheckLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate max-w-[150px]">
{selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`}
{selectedIssue
? t('session.githubIntegration.selected.issueNumber', { number: selectedIssue.number })
: t('session.githubIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
</span>
<button
onClick={handleClear}
@@ -490,10 +496,10 @@ export function GitHubIntegrationDialog({
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel="Include PR diff in session context"
ariaLabel={t('session.githubIntegration.includeDiffAria')}
/>
<span className="typography-small text-foreground">
Include PR diff
{t('session.githubIntegration.includeDiff')}
</span>
</label>
)}
@@ -510,7 +516,7 @@ export function GitHubIntegrationDialog({
onClick={() => onOpenChange(false)}
className={cn(isMobile && 'flex-1')}
>
Cancel
{t('session.githubIntegration.actions.cancel')}
</Button>
<Button
size="sm"
@@ -518,7 +524,7 @@ export function GitHubIntegrationDialog({
disabled={!canConfirm}
className={cn(isMobile && 'flex-1')}
>
Select
{t('session.githubIntegration.actions.select')}
</Button>
</div>
</div>
@@ -529,21 +535,21 @@ export function GitHubIntegrationDialog({
{isMobile ? (
<MobileOverlayPanel
open={open}
title="Select from GitHub"
title={t('session.githubIntegration.title')}
onClose={() => onOpenChange(false)}
footer={!isGitHubConnected ? undefined : footerContent}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-2 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">Select from GitHub</h2>
<h2 className="typography-ui-label font-semibold text-foreground">{t('session.githubIntegration.title')}</h2>
{closeButton}
</div>
{/* Tabs - using SortableTabsStrip */}
<div className="w-full">
<SortableTabsStrip
items={[
{ id: 'issues', label: 'Issues', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: 'Pull Requests', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
{ id: 'issues', label: t('session.githubIntegration.tabs.issues'), icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: t('session.githubIntegration.tabs.pullRequests'), icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
@@ -560,7 +566,9 @@ export function GitHubIntegrationDialog({
<div className="flex items-center gap-2 px-2 py-1 rounded-md bg-muted/50 border border-border/50">
<RiCheckLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate flex-1">
{selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`}
{selectedIssue
? t('session.githubIntegration.selected.issueNumber', { number: selectedIssue.number })
: t('session.githubIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
</span>
<button
onClick={handleClear}
@@ -582,15 +590,15 @@ export function GitHubIntegrationDialog({
<div className="flex items-center gap-3">
<DialogTitle className="flex items-center gap-2 shrink-0">
<RiGithubLine className="h-5 w-5" />
Select from GitHub
{t('session.githubIntegration.title')}
</DialogTitle>
{/* Tabs - using SortableTabsStrip */}
<div className="w-[220px]">
<SortableTabsStrip
items={[
{ id: 'issues', label: 'Issues', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: 'Pull Requests', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
{ id: 'issues', label: t('session.githubIntegration.tabs.issues'), icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: t('session.githubIntegration.tabs.pullRequests'), icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
@@ -33,6 +33,7 @@ import { renderMagicPrompt } from '@/lib/magicPrompts';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const parseIssueNumber = (value: string): number | null => {
const trimmed = value.trim();
@@ -77,6 +78,7 @@ export function GitHubIssuePickerDialog({
mode?: 'createSession' | 'select';
onSelect?: (issue: { number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string } }) => void;
}) {
const { t } = useI18n();
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -101,7 +103,7 @@ export function GitHubIssuePickerDialog({
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
setError(t('session.githubIssuePicker.error.noActiveProject'));
return;
}
if (githubAuthChecked && githubAuthStatus?.connected === false) {
@@ -114,7 +116,7 @@ export function GitHubIssuePickerDialog({
}
if (!github?.issuesList) {
setResult(null);
setError('GitHub runtime API unavailable');
setError(t('session.githubIssuePicker.error.runtimeUnavailable'));
return;
}
@@ -152,11 +154,11 @@ export function GitHubIssuePickerDialog({
setHasMore(Boolean(next.hasMore));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load more issues', { description: message });
toast.error(t('session.githubIssuePicker.toast.loadMoreFailed'), { description: message });
} finally {
setIsLoadingMore(false);
}
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory, t]);
React.useEffect(() => {
if (!open) {
@@ -270,11 +272,11 @@ export function GitHubIssuePickerDialog({
if (mode === 'select') {
// In select mode, fetch full issue details and return via onSelect
if (!projectDirectory) {
toast.error('No active project');
toast.error(t('session.githubIssuePicker.error.noActiveProject'));
return;
}
if (!github?.issueGet || !github?.issueComments) {
toast.error('GitHub runtime API unavailable');
toast.error(t('session.githubIssuePicker.error.runtimeUnavailable'));
return;
}
if (startingIssueNumber) return;
@@ -282,24 +284,24 @@ export function GitHubIssuePickerDialog({
try {
const issueRes = await github.issueGet(projectDirectory, issueNumber);
if (issueRes.connected === false) {
toast.error('GitHub not connected');
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
}
if (!issueRes.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
toast.error(t('session.githubIssuePicker.error.repoNotResolvable'), {
description: t('session.githubIssuePicker.error.repoMustBeGithub'),
});
return;
}
const issue = issueRes.issue;
if (!issue) {
toast.error('Issue not found');
toast.error(t('session.githubIssuePicker.error.issueNotFound'));
return;
}
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
if (commentsRes.connected === false) {
toast.error('GitHub not connected');
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
}
const comments = commentsRes.comments ?? [];
@@ -322,7 +324,7 @@ export function GitHubIssuePickerDialog({
onOpenChange(false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load issue details', { description: message });
toast.error(t('session.githubIssuePicker.toast.loadIssueDetailsFailed'), { description: message });
} finally {
setStartingIssueNumber(null);
}
@@ -330,11 +332,11 @@ export function GitHubIssuePickerDialog({
}
if (!projectDirectory) {
toast.error('No active project');
toast.error(t('session.githubIssuePicker.error.noActiveProject'));
return;
}
if (!github?.issueGet || !github?.issueComments) {
toast.error('GitHub runtime API unavailable');
toast.error(t('session.githubIssuePicker.error.runtimeUnavailable'));
return;
}
if (startingIssueNumber) return;
@@ -342,24 +344,24 @@ export function GitHubIssuePickerDialog({
try {
const issueRes = await github.issueGet(projectDirectory, issueNumber);
if (issueRes.connected === false) {
toast.error('GitHub not connected');
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
}
if (!issueRes.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
toast.error(t('session.githubIssuePicker.error.repoNotResolvable'), {
description: t('session.githubIssuePicker.error.repoMustBeGithub'),
});
return;
}
const issue = issueRes.issue;
if (!issue) {
toast.error('Issue not found');
toast.error(t('session.githubIssuePicker.error.issueNotFound'));
return;
}
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
if (commentsRes.connected === false) {
toast.error('GitHub not connected');
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
}
const comments = commentsRes.comments ?? [];
@@ -406,7 +408,7 @@ export function GitHubIssuePickerDialog({
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
if (!providerID || !modelID) {
toast.error('No model selected');
toast.error(t('session.githubIssuePicker.error.noModelSelected'));
return;
}
@@ -470,31 +472,31 @@ export function GitHubIssuePickerDialog({
],
}).catch((e) => {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to send issue context', {
toast.error(t('session.githubIssuePicker.toast.sendContextFailed'), {
description: message,
});
});
toast.success('Session created from issue');
toast.success(t('session.githubIssuePicker.toast.sessionCreated'));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to start session', { description: message });
toast.error(t('session.githubIssuePicker.toast.startSessionFailed'), { description: message });
} finally {
setStartingIssueNumber(null);
}
}, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
}, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber, t]);
const title = mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue';
const title = mode === 'select' ? t('session.githubIssuePicker.title.select') : t('session.githubIssuePicker.title.createSession');
const description = mode === 'select'
? 'Select an issue to link to this session.'
: 'Seeds a new session with hidden issue context (title/body/labels/comments).';
? t('session.githubIssuePicker.description.select')
: t('session.githubIssuePicker.description.createSession');
const content = (
<>
<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 by title or #123, or paste issue URL"
placeholder={t('session.githubIssuePicker.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
@@ -503,26 +505,26 @@ export function GitHubIssuePickerDialog({
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
<div className="text-center text-muted-foreground py-8">{t('session.githubIssuePicker.empty.noActiveProject')}</div>
) : null}
{!github ? (
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
<div className="text-center text-muted-foreground py-8">{t('session.githubIssuePicker.empty.runtimeUnavailable')}</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading issues...
{t('session.githubIssuePicker.loading.issues')}
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>GitHub not connected. Connect your GitHub account in settings.</div>
<div>{t('session.githubIssuePicker.empty.notConnected')}</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
Open settings
{t('session.githubIssuePicker.actions.openSettings')}
</Button>
</div>
</div>
@@ -542,7 +544,7 @@ export function GitHubIssuePickerDialog({
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
Use issue #{directNumber}
{t('session.githubIssuePicker.actions.useIssue', { number: directNumber })}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === directNumber ? (
@@ -553,7 +555,7 @@ export function GitHubIssuePickerDialog({
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? 'No issues found' : 'No open issues found'}</div>
<div className="text-center text-muted-foreground py-8">{query ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}</div>
) : null}
{filtered.map((issue) => (
@@ -582,7 +584,7 @@ export function GitHubIssuePickerDialog({
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
aria-label={t('session.githubIssuePicker.actions.openInGitHubAria')}
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
@@ -605,10 +607,10 @@ export function GitHubIssuePickerDialog({
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
{t('session.githubIssuePicker.loading.more')}
</span>
) : (
'Load more'
t('session.githubIssuePicker.actions.loadMore')
)}
</button>
</div>
@@ -617,7 +619,7 @@ export function GitHubIssuePickerDialog({
{mode !== 'select' && (
<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('session.githubIssuePicker.actions.sectionTitle')}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
<div
className="flex items-center gap-2 cursor-pointer"
@@ -639,7 +641,7 @@ export function GitHubIssuePickerDialog({
e.stopPropagation();
setCreateInWorktree((v) => !v);
}}
aria-label="Toggle worktree"
aria-label={t('session.githubIssuePicker.actions.toggleWorktreeAria')}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{createInWorktree ? (
@@ -648,7 +650,7 @@ export function GitHubIssuePickerDialog({
<RiCheckboxBlankLine className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">Create in worktree</span>
<span className="typography-meta text-muted-foreground">{t('session.githubIssuePicker.actions.createInWorktree')}</span>
<span className="typography-meta text-muted-foreground/70 hidden sm:inline">(issue-&lt;number&gt;-&lt;slug&gt;)</span>
</div>
<div className="hidden sm:block sm:flex-1" />
@@ -657,12 +659,12 @@ export function GitHubIssuePickerDialog({
<Button variant="outline" size="sm" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Open Repo
{t('session.githubIssuePicker.actions.openRepo')}
</a>
</Button>
) : null}
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingIssueNumber)}>
Refresh
{t('session.githubIssuePicker.actions.refresh')}
</Button>
</div>
</div>
@@ -24,6 +24,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const parsePrNumber = (value: string): number | null => {
const trimmed = value.trim();
@@ -67,6 +68,7 @@ export function GitHubPrPickerDialog({
author?: { login: string; avatarUrl?: string };
}) => void;
}) {
const { t } = useI18n();
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -91,7 +93,7 @@ export function GitHubPrPickerDialog({
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
setError(t('session.githubPrPicker.error.noActiveProject'));
return;
}
if (githubAuthChecked && githubAuthStatus?.connected === false) {
@@ -104,7 +106,7 @@ export function GitHubPrPickerDialog({
}
if (!github?.prsList) {
setResult(null);
setError('GitHub runtime API unavailable');
setError(t('session.githubPrPicker.error.runtimeUnavailable'));
return;
}
@@ -142,11 +144,11 @@ export function GitHubPrPickerDialog({
setHasMore(Boolean(next.hasMore));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load more pull requests', { description: message });
toast.error(t('session.githubPrPicker.toast.loadMoreFailed'), { description: message });
} finally {
setIsLoadingMore(false);
}
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory, t]);
React.useEffect(() => {
if (!open) {
@@ -195,11 +197,11 @@ export function GitHubPrPickerDialog({
const attachPr = React.useCallback(async (prNumber: number) => {
if (!projectDirectory) {
toast.error('No active project');
toast.error(t('session.githubPrPicker.error.noActiveProject'));
return;
}
if (!github?.prContext) {
toast.error('GitHub runtime API unavailable');
toast.error(t('session.githubPrPicker.error.runtimeUnavailable'));
return;
}
if (loadingPrNumber) return;
@@ -212,18 +214,18 @@ export function GitHubPrPickerDialog({
});
if (context.connected === false) {
toast.error('GitHub not connected');
toast.error(t('session.githubPrPicker.error.notConnected'));
return;
}
if (!context.pr) {
toast.error('Pull request not found');
toast.error(t('session.githubPrPicker.error.prNotFound'));
return;
}
if (!context.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
toast.error(t('session.githubPrPicker.error.repoNotResolvable'), {
description: t('session.githubPrPicker.error.repoMustBeGithub'),
});
return;
}
@@ -250,14 +252,14 @@ export function GitHubPrPickerDialog({
onOpenChange(false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load pull request details', { description: message });
toast.error(t('session.githubPrPicker.toast.loadDetailsFailed'), { description: message });
} finally {
setLoadingPrNumber(null);
}
}, [github, includeDiff, loadingPrNumber, onOpenChange, onSelect, projectDirectory]);
}, [github, includeDiff, loadingPrNumber, onOpenChange, onSelect, projectDirectory, t]);
const title = 'Link GitHub Pull Request';
const description = 'Select a pull request to attach review context to this message.';
const title = t('session.githubPrPicker.title');
const description = t('session.githubPrPicker.description');
const content = (
<>
@@ -265,7 +267,7 @@ export function GitHubPrPickerDialog({
<div className="relative flex-1 min-w-0">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste pull request URL"
placeholder={t('session.githubPrPicker.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
@@ -276,41 +278,41 @@ export function GitHubPrPickerDialog({
onClick={() => setIncludeDiff((prev) => !prev)}
className="h-9 shrink-0 flex items-center gap-2 text-left"
aria-pressed={includeDiff}
aria-label="Include PR diff in attached context"
aria-label={t('session.githubPrPicker.includeDiffAria')}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel="Include PR diff in attached context"
ariaLabel={t('session.githubPrPicker.includeDiffAria')}
/>
</span>
<span className="typography-small text-muted-foreground whitespace-nowrap">Include PR diff</span>
<span className="typography-small text-muted-foreground whitespace-nowrap">{t('session.githubPrPicker.includeDiff')}</span>
</button>
</div>
<div className={cn(isMobile ? 'min-h-0' : 'flex-1 overflow-y-auto')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
<div className="text-center text-muted-foreground py-8">{t('session.githubPrPicker.empty.noActiveProject')}</div>
) : null}
{!github ? (
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
<div className="text-center text-muted-foreground py-8">{t('session.githubPrPicker.empty.runtimeUnavailable')}</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading pull requests...
{t('session.githubPrPicker.loading.pullRequests')}
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>GitHub not connected. Connect your GitHub account in settings.</div>
<div>{t('session.githubPrPicker.empty.notConnected')}</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
Open settings
{t('session.githubPrPicker.actions.openSettings')}
</Button>
</div>
</div>
@@ -330,7 +332,7 @@ export function GitHubPrPickerDialog({
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
Use pull request #{directNumber}
{t('session.githubPrPicker.actions.usePullRequest', { number: directNumber })}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingPrNumber === directNumber ? (
@@ -341,7 +343,7 @@ export function GitHubPrPickerDialog({
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? 'No pull requests found' : 'No open pull requests found'}</div>
<div className="text-center text-muted-foreground py-8">{query ? t('session.githubPrPicker.empty.noPullRequestsFound') : t('session.githubPrPicker.empty.noOpenPullRequestsFound')}</div>
) : null}
{filtered.map((pr) => (
@@ -371,7 +373,7 @@ export function GitHubPrPickerDialog({
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
aria-label={t('session.githubPrPicker.actions.openInGitHubAria')}
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
@@ -394,10 +396,10 @@ export function GitHubPrPickerDialog({
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
{t('session.githubPrPicker.loading.more')}
</span>
) : (
'Load more'
t('session.githubPrPicker.actions.loadMore')
)}
</button>
</div>
@@ -65,6 +65,7 @@ import type {
GitHubPullRequestSummary,
} from '@/lib/api/types';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { useI18n } from '@/lib/i18n';
type Mode = 'new-branch' | 'existing-branch';
@@ -203,6 +204,7 @@ export function NewWorktreeDialog({
onOpenChange,
onWorktreeCreated,
}: NewWorktreeDialogProps) {
const { t } = useI18n();
const { github, git } = useRuntimeAPIs();
const isMobile = useUIStore((state) => state.isMobile);
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
@@ -519,7 +521,7 @@ export function NewWorktreeDialog({
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
if (!providerID || !modelID) {
toast.error('No model selected');
toast.error(t('session.newWorktree.error.noModelSelected'));
return;
}
@@ -571,7 +573,7 @@ export function NewWorktreeDialog({
],
});
toast.success('Session created from issue');
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
return;
}
@@ -607,7 +609,7 @@ export function NewWorktreeDialog({
],
});
toast.success('Session created from PR');
toast.success(t('session.newWorktree.toast.sessionFromPr'));
}
}, [
applySessionModelAndAgentDefaults,
@@ -729,11 +731,11 @@ export function NewWorktreeDialog({
let worktreeError: string | null = null;
if (!normalizedBranch) {
branchError = 'Branch name is required';
branchError = t('session.newWorktree.error.branchNameRequired');
}
if (!normalizedWorktree) {
worktreeError = 'Worktree directory is required';
worktreeError = t('session.newWorktree.error.worktreeDirectoryRequired');
}
// Only run server validation if we have values
@@ -805,7 +807,7 @@ export function NewWorktreeDialog({
// Handle worktree creation
const handleCreate = async () => {
if (!projectRef || !projectDirectory) {
toast.error('No active project');
toast.error(t('session.newWorktree.error.noActiveProject'));
return;
}
@@ -818,12 +820,12 @@ export function NewWorktreeDialog({
const normalizedWorktree = slugifyWorktreeName(worktreeName);
if (!normalizedBranch) {
toast.error('Branch name is required');
toast.error(t('session.newWorktree.error.branchNameRequired'));
return;
}
if (!normalizedWorktree) {
toast.error('Worktree directory is required');
toast.error(t('session.newWorktree.error.worktreeDirectoryRequired'));
return;
}
@@ -892,7 +894,7 @@ export function NewWorktreeDialog({
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
: linkedPrState
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
: 'New session';
: t('session.newWorktree.newSessionTitle');
const session = await sessionActions.createSession(sessionTitle, metadata.path, null);
if (!session?.id) {
@@ -914,8 +916,10 @@ export function NewWorktreeDialog({
localStorage.setItem(LAST_SOURCE_BRANCH_KEY, newBranchState.sourceBranch);
}
toast.success('Worktree created', {
description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''} - bootstrapping in background`,
toast.success(t('session.newWorktree.toast.worktreeCreated'), {
description: t('session.newWorktree.toast.worktreeCreatedDescription', {
target: `${metadata.branch || metadata.name}${sourceLabel ? ` ${t('session.newWorktree.fromSource', { source: sourceLabel })}` : ''}`,
}),
});
onOpenChange(false);
@@ -928,15 +932,15 @@ export function NewWorktreeDialog({
pr: linkedPrState,
includeDiff: includePrDiff,
}).catch((error) => {
const message = error instanceof Error ? error.message : 'Failed to send GitHub context';
toast.error('Failed to send GitHub context', { description: message });
const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed');
toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message });
});
} else {
onWorktreeCreated?.(metadata.path);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create worktree';
toast.error('Failed to create worktree', { description: message });
const message = error instanceof Error ? error.message : t('session.newWorktree.error.createWorktreeFailed');
toast.error(t('session.newWorktree.error.createWorktreeFailed'), { description: message });
} finally {
setIsCreating(false);
}
@@ -1036,7 +1040,7 @@ export function NewWorktreeDialog({
disabled={isCreating}
className={cn(isMobile && 'flex-1')}
>
Cancel
{t('session.newWorktree.actions.cancel')}
</Button>
<Button
size="sm"
@@ -1045,7 +1049,7 @@ export function NewWorktreeDialog({
className={cn('gap-1.5', isMobile && 'flex-1')}
>
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
{isCreating ? 'Creating...' : 'Create Worktree'}
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
</Button>
</div>
</div>
@@ -1056,7 +1060,7 @@ export function NewWorktreeDialog({
{isMobile ? (
<MobileOverlayPanel
open={open}
title="New Worktree"
title={t('session.newWorktree.title')}
onClose={() => onOpenChange(false)}
footer={footerContent}
>
@@ -1064,8 +1068,8 @@ export function NewWorktreeDialog({
<div className="w-full mb-4">
<SortableTabsStrip
items={[
{ id: 'new-branch', label: 'New Branch', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: 'Existing Branch', icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
@@ -1080,7 +1084,7 @@ export function NewWorktreeDialog({
{mode === 'existing-branch' ? (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Select Branch
{t('session.newWorktree.selectBranch')}
</label>
<div className="flex items-center gap-2">
<Button
@@ -1090,7 +1094,7 @@ export function NewWorktreeDialog({
className="flex-1 justify-between h-9"
>
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
{existingBranchState.selectedBranch || 'Choose a branch...'}
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
</Button>
@@ -1100,7 +1104,7 @@ export function NewWorktreeDialog({
className="h-8 w-8 px-0 shrink-0"
onClick={handleFetchBranches}
disabled={!canFetchBranches || isLoadingBranches}
title="Fetch branches"
title={t('session.newWorktree.fetchBranches')}
>
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
</Button>
@@ -1109,30 +1113,30 @@ export function NewWorktreeDialog({
{/* Mobile Branch Picker Overlay */}
<MobileOverlayPanel
open={existingBranchPickerOpen}
title="Select Branch"
title={t('session.newWorktree.selectBranch')}
onClose={() => setExistingBranchPickerOpen(false)}
>
<div className="space-y-4" ref={existingBranchMobileListWrapperRef}>
<Input
value={existingBranchQuery}
onChange={(e) => setExistingBranchQuery(e.target.value)}
placeholder="Search branches..."
placeholder={t('session.newWorktree.searchBranches')}
className="h-8"
/>
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
{t('session.newWorktree.loadingBranches')}
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
No branches found
{t('session.newWorktree.noBranchesFound')}
</div>
) : (
<div className="space-y-4">
{hasExistingBranchQuery && hasExistingBranchMatches && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Matching branches
{t('session.newWorktree.matchingBranches')}
</div>
<div className="space-y-1">
{existingBranchRankedGroups.matching.map((branch) => (
@@ -1163,14 +1167,14 @@ export function NewWorktreeDialog({
{hasExistingBranchQuery && !hasExistingBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
{t('session.newWorktree.noMatchingBranches')}
</div>
)}
{existingBranchRankedGroups.otherLocal.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
{hasExistingBranchQuery ? 'Other local branches' : 'Local branches'}
{hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
</div>
<div className="space-y-1">
{existingBranchRankedGroups.otherLocal.map((branch) => (
@@ -1202,7 +1206,7 @@ export function NewWorktreeDialog({
{existingBranchRankedGroups.otherRemote.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
{hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}
{hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
</div>
<div className="space-y-1">
{existingBranchRankedGroups.otherRemote.map((branch) => (
@@ -1239,7 +1243,7 @@ export function NewWorktreeDialog({
<div className="space-y-1.5">
<div className="flex flex-col items-start gap-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Branch Name
{t('session.newWorktree.branchName')}
</label>
{mode === 'new-branch' && isGitHubConnected && (
<Button
@@ -1249,7 +1253,7 @@ export function NewWorktreeDialog({
className="gap-1.5 h-7"
>
<RiGithubLine className="size-4 text-status-success" />
{newBranchState.linkedIssue || newBranchState.linkedPr ? 'Change' : 'Start from GitHub Issue/PR'}
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
</Button>
)}
</div>
@@ -1265,7 +1269,7 @@ export function NewWorktreeDialog({
}));
}}
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
placeholder="feature/my-awesome-feature"
placeholder={t('session.newWorktree.branchNamePlaceholder')}
disabled={!!newBranchState.linkedPr}
className={cn(
'h-8',
@@ -1277,7 +1281,7 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
Using PR branch: {newBranchState.linkedPr.head}
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
</span>
</div>
)}
@@ -1285,7 +1289,7 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title}
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
</span>
</div>
)}
@@ -1296,7 +1300,7 @@ export function NewWorktreeDialog({
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui-label text-foreground font-semibold">
Worktree Directory
{t('session.newWorktree.worktreeDirectory')}
</label>
{mode !== 'existing-branch' && (
<button
@@ -1315,10 +1319,10 @@ export function NewWorktreeDialog({
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title="Reset to match branch name"
title={t('session.newWorktree.resetToMatchBranchName')}
>
<RiRefreshLine className="h-3 w-3" />
<span>Reset</span>
<span>{t('session.newWorktree.actions.reset')}</span>
</button>
)}
</div>
@@ -1339,7 +1343,7 @@ export function NewWorktreeDialog({
}
}}
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
placeholder="my-worktree-directory"
placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')}
className={cn(
'h-8',
validation.touched && validation.worktreeError && 'border-destructive'
@@ -1351,7 +1355,7 @@ export function NewWorktreeDialog({
{mode === 'new-branch' && !newBranchState.linkedPr && (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Source Branch
{t('session.newWorktree.sourceBranch')}
</label>
<Button
variant="outline"
@@ -1360,43 +1364,43 @@ export function NewWorktreeDialog({
className="w-full justify-between h-9"
>
<span className={newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground'}>
{newBranchState.sourceBranch || 'Select source branch...'}
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
</Button>
{newBranchState.sourceBranch && (
<div className="typography-micro text-muted-foreground">
New branch will be created from {newBranchState.sourceBranch}
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
</div>
)}
{/* Mobile Source Branch Picker Overlay */}
<MobileOverlayPanel
open={sourceBranchPickerOpen}
title="Select Source Branch"
title={t('session.newWorktree.selectSourceBranch')}
onClose={() => setSourceBranchPickerOpen(false)}
>
<div className="space-y-4" ref={sourceBranchMobileListWrapperRef}>
<Input
value={sourceBranchQuery}
onChange={(e) => setSourceBranchQuery(e.target.value)}
placeholder="Search branches..."
placeholder={t('session.newWorktree.searchBranches')}
className="h-8"
/>
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
{t('session.newWorktree.loadingBranches')}
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
No branches found
{t('session.newWorktree.noBranchesFound')}
</div>
) : (
<div className="space-y-4">
{hasSourceBranchQuery && hasSourceBranchMatches && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Matching branches
{t('session.newWorktree.matchingBranches')}
</div>
<div className="space-y-1">
{sourceBranchRankedGroups.matching.map((branch) => (
@@ -1422,14 +1426,14 @@ export function NewWorktreeDialog({
{hasSourceBranchQuery && !hasSourceBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
{t('session.newWorktree.noMatchingBranches')}
</div>
)}
{sourceBranchRankedGroups.otherLocal.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
{hasSourceBranchQuery ? 'Other local branches' : 'Local branches'}
{hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
</div>
<div className="space-y-1">
{sourceBranchRankedGroups.otherLocal.map((branch) => (
@@ -1456,7 +1460,7 @@ export function NewWorktreeDialog({
{sourceBranchRankedGroups.otherRemote.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
{hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}
{hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
</div>
<div className="space-y-1">
{sourceBranchRankedGroups.otherRemote.map((branch) => (
@@ -1493,16 +1497,16 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-2">
<RiGithubLine className="h-3.5 w-3.5 text-status-success shrink-0" />
{newBranchState.linkedIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
Issue #{newBranchState.linkedIssue.number}
</span>
)}
{newBranchState.linkedPr && (
<span className="typography-micro text-muted-foreground shrink-0">
PR #{newBranchState.linkedPr.number}
</span>
)}
{newBranchState.linkedIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
</span>
)}
{newBranchState.linkedPr && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
</span>
)}
<span className="typography-micro text-foreground truncate flex-1">
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
@@ -1532,11 +1536,11 @@ export function NewWorktreeDialog({
<span className="typography-micro text-muted-foreground">
{newBranchState.linkedPr.head} {newBranchState.linkedPr.base}
</span>
{newBranchState.includePrDiff && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
+diff
</span>
)}
{newBranchState.includePrDiff && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
{t('session.newWorktree.includeDiffBadge')}
</span>
)}
</div>
)}
</div>
@@ -1550,15 +1554,15 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-3">
<DialogTitle className="flex items-center gap-2 shrink-0">
<RiGitBranchLine className="h-5 w-5" />
New Worktree
{t('session.newWorktree.title')}
</DialogTitle>
{/* Mode Selection - using SortableTabsStrip */}
<div className="w-[280px] shrink-0">
<SortableTabsStrip
items={[
{ id: 'new-branch', label: 'New Branch', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: 'Existing Branch', icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
@@ -1575,14 +1579,14 @@ export function NewWorktreeDialog({
{mode === 'existing-branch' ? (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Select Branch
{t('session.newWorktree.selectBranch')}
</label>
<div className="flex items-center gap-2">
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
{existingBranchState.selectedBranch || 'Choose a branch...'}
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
</span>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
@@ -1590,21 +1594,21 @@ export function NewWorktreeDialog({
<DropdownMenuContent align="start" sideOffset={6} className="w-[320px] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={existingBranchDropdownContentRef}>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search branches..."
placeholder={t('session.newWorktree.searchBranches')}
value={existingBranchQuery}
onValueChange={setExistingBranchQuery}
/>
<CommandList disableHorizontal>
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
{t('session.newWorktree.loadingBranches')}
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<CommandEmpty>No branches found</CommandEmpty>
<CommandEmpty>{t('session.newWorktree.noBranchesFound')}</CommandEmpty>
) : (
<>
{hasExistingBranchQuery && hasExistingBranchMatches && (
<CommandGroup heading="Matching branches">
<CommandGroup heading={t('session.newWorktree.matchingBranches')}>
{existingBranchRankedGroups.matching.map((branch) => (
<CommandItem
key={`${branch.source}-${branch.value}`}
@@ -1627,14 +1631,14 @@ export function NewWorktreeDialog({
{hasExistingBranchQuery && !hasExistingBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
{t('session.newWorktree.noMatchingBranches')}
</div>
)}
{existingBranchRankedGroups.otherLocal.length > 0 && (
<>
{hasExistingBranchQuery && <CommandSeparator />}
<CommandGroup heading={hasExistingBranchQuery ? 'Other local branches' : 'Local branches'}>
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
{existingBranchRankedGroups.otherLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
@@ -1661,7 +1665,7 @@ export function NewWorktreeDialog({
{(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
<CommandSeparator />
)}
<CommandGroup heading={hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}>
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
{existingBranchRankedGroups.otherRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
@@ -1694,7 +1698,7 @@ export function NewWorktreeDialog({
className="h-8 w-8 px-0 shrink-0"
onClick={handleFetchBranches}
disabled={!canFetchBranches || isLoadingBranches}
title="Fetch branches"
title={t('session.newWorktree.fetchBranches')}
>
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
</Button>
@@ -1704,7 +1708,7 @@ export function NewWorktreeDialog({
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui-label text-foreground block font-semibold">
Branch Name
{t('session.newWorktree.branchName')}
</label>
{mode === 'new-branch' && isGitHubConnected && (
<Button
@@ -1714,7 +1718,7 @@ export function NewWorktreeDialog({
className="gap-1.5 h-7"
>
<RiGithubLine className="size-4 text-status-success" />
{newBranchState.linkedIssue || newBranchState.linkedPr ? 'Change' : 'Start from GitHub Issue/PR'}
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
</Button>
)}
</div>
@@ -1730,7 +1734,7 @@ export function NewWorktreeDialog({
}));
}}
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
placeholder="feature/my-awesome-feature"
placeholder={t('session.newWorktree.branchNamePlaceholder')}
disabled={!!newBranchState.linkedPr}
className={cn(
'h-8',
@@ -1742,7 +1746,7 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
Using PR branch: {newBranchState.linkedPr.head}
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
</span>
</div>
)}
@@ -1750,7 +1754,7 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title}
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
</span>
</div>
)}
@@ -1761,7 +1765,7 @@ export function NewWorktreeDialog({
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui-label text-foreground font-semibold">
Worktree Directory
{t('session.newWorktree.worktreeDirectory')}
</label>
{mode !== 'existing-branch' && (
<button
@@ -1780,10 +1784,10 @@ export function NewWorktreeDialog({
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title="Reset to match branch name"
title={t('session.newWorktree.resetToMatchBranchName')}
>
<RiRefreshLine className="h-3 w-3" />
<span>Reset</span>
<span>{t('session.newWorktree.actions.reset')}</span>
</button>
)}
</div>
@@ -1804,7 +1808,7 @@ export function NewWorktreeDialog({
}
}}
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
placeholder="my-worktree-directory"
placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')}
className={cn(
'h-8',
validation.touched && validation.worktreeError && 'border-destructive'
@@ -1815,14 +1819,14 @@ export function NewWorktreeDialog({
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
{mode === 'new-branch' && !newBranchState.linkedPr && (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
Source Branch
</label>
<label className="typography-ui-label text-foreground block font-semibold">
{t('session.newWorktree.sourceBranch')}
</label>
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
{newBranchState.sourceBranch || 'Select source branch...'}
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
</span>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
@@ -1830,21 +1834,21 @@ export function NewWorktreeDialog({
<DropdownMenuContent align="start" className="w-[320px] p-0" ref={sourceBranchDropdownContentRef}>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search branches..."
placeholder={t('session.newWorktree.searchBranches')}
value={sourceBranchQuery}
onValueChange={setSourceBranchQuery}
/>
<CommandList disableHorizontal>
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
{t('session.newWorktree.loadingBranches')}
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<CommandEmpty>No branches found</CommandEmpty>
<CommandEmpty>{t('session.newWorktree.noBranchesFound')}</CommandEmpty>
) : (
<>
{hasSourceBranchQuery && hasSourceBranchMatches && (
<CommandGroup heading="Matching branches">
<CommandGroup heading={t('session.newWorktree.matchingBranches')}>
{sourceBranchRankedGroups.matching.map((branch) => (
<CommandItem
key={`${branch.source}-${branch.value}`}
@@ -1862,14 +1866,14 @@ export function NewWorktreeDialog({
{hasSourceBranchQuery && !hasSourceBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
{t('session.newWorktree.noMatchingBranches')}
</div>
)}
{sourceBranchRankedGroups.otherLocal.length > 0 && (
<>
{hasSourceBranchQuery && <CommandSeparator />}
<CommandGroup heading={hasSourceBranchQuery ? 'Other local branches' : 'Local branches'}>
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
{sourceBranchRankedGroups.otherLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
@@ -1891,7 +1895,7 @@ export function NewWorktreeDialog({
{(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
<CommandSeparator />
)}
<CommandGroup heading={hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}>
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
{sourceBranchRankedGroups.otherRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
@@ -1915,7 +1919,7 @@ export function NewWorktreeDialog({
</DropdownMenu>
{newBranchState.sourceBranch && (
<div className="typography-micro text-muted-foreground">
New branch will be created from {newBranchState.sourceBranch}
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
</div>
)}
</div>
@@ -1930,12 +1934,12 @@ export function NewWorktreeDialog({
{newBranchState.linkedIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
Issue #{newBranchState.linkedIssue.number}
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
</span>
)}
{newBranchState.linkedPr && (
<span className="typography-micro text-muted-foreground shrink-0">
PR #{newBranchState.linkedPr.number}
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
</span>
)}
@@ -1969,7 +1973,7 @@ export function NewWorktreeDialog({
</span>
{newBranchState.includePrDiff && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
+diff
{t('session.newWorktree.includeDiffBadge')}
</span>
)}
</div>
@@ -1999,7 +2003,7 @@ export function NewWorktreeDialog({
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
Cancel
{t('session.newWorktree.actions.cancel')}
</Button>
<Button
size="sm"
@@ -2008,7 +2012,7 @@ export function NewWorktreeDialog({
className="gap-1.5"
>
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
{isCreating ? 'Creating...' : 'Create Worktree'}
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
</Button>
</div>
</DialogFooter>
@@ -32,6 +32,7 @@ import { useInputStore } from '@/sync/input-store';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { cn } from '@/lib/utils';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { useI18n } from '@/lib/i18n';
import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog';
interface ProjectNotesTodoPanelProps {
@@ -52,11 +53,14 @@ type ProjectPlanListItem = OpenChamberProjectPlanFileLink & {
title: string;
};
const toPlanListItem = async (plan: OpenChamberProjectPlanFileLink): Promise<ProjectPlanListItem> => {
const toPlanListItem = async (
plan: OpenChamberProjectPlanFileLink,
fallbackTitle: string,
): Promise<ProjectPlanListItem> => {
const file = await readProjectPlanFile(plan.path);
return {
...plan,
title: file?.title || plan.path.split('/').pop() || 'Plan',
title: file?.title || plan.path.split('/').pop() || fallbackTitle,
};
};
@@ -74,6 +78,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
onActionComplete,
className,
}) => {
const { t } = useI18n();
const [isLoading, setIsLoading] = React.useState(false);
const [notes, setNotes] = React.useState('');
const [todos, setTodos] = React.useState<OpenChamberProjectTodoItem[]>([]);
@@ -108,11 +113,11 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
todos: nextTodos,
});
if (!saved) {
toast.error('Failed to save project notes');
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
return saved;
},
[projectRef]
[projectRef, t]
);
React.useEffect(() => {
@@ -131,7 +136,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
(async () => {
try {
const data = await getProjectContextData(projectRef);
const nextPlans = await Promise.all(data.plans.map((plan) => toPlanListItem(plan)));
const nextPlans = await Promise.all(
data.plans.map((plan) => toPlanListItem(plan, t('rightSidebar.contextNotesTodo.plan.defaultTitle')))
);
if (cancelled) {
return;
}
@@ -144,7 +151,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
setExpandedTodoIds(new Set());
} catch {
if (!cancelled) {
toast.error('Failed to load project notes');
toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed'));
setNotes('');
setTodos([]);
setPlans([]);
@@ -161,7 +168,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
return () => {
cancelled = true;
};
}, [contextReloadTick, projectRef]);
}, [contextReloadTick, projectRef, t]);
React.useEffect(() => {
if (!projectRef) {
@@ -288,16 +295,16 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const handleSendToCurrentSession = React.useCallback(
(todoText: string) => {
if (!currentSessionId) {
toast.error('No active session selected');
toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession'));
return;
}
routeToChat();
const fenced = `\`\`\`md\n${todoText}\n\`\`\``;
setPendingInputText(fenced, 'append');
toast.success('Todo sent to current session');
toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession'));
onActionComplete?.();
},
[currentSessionId, onActionComplete, routeToChat, setPendingInputText]
[currentSessionId, onActionComplete, routeToChat, setPendingInputText, t]
);
const handleSendToNewWorktreeSession = React.useCallback(
@@ -306,12 +313,12 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
return;
}
if (!canCreateWorktree) {
toast.error('Worktree actions are only available for Git repositories');
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
setPendingSendTarget({ kind: 'worktree', todoId, todoText });
},
[canCreateWorktree, projectRef, sendingTodoId]
[canCreateWorktree, projectRef, sendingTodoId, t]
);
const handleConfirmSend = React.useCallback(
@@ -339,7 +346,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
if (pendingSendTarget.kind === 'worktree') {
if (!canCreateWorktree) {
toast.error('Worktree actions are only available for Git repositories');
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName());
@@ -351,7 +358,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
} else {
const session = await createSession(undefined, projectRef.path, null);
if (!session?.id) {
toast.error('Failed to create session');
toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed'));
return;
}
sessionId = session.id;
@@ -391,20 +398,20 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
toast.success(
pendingSendTarget.kind === 'worktree'
? 'Todo sent to new worktree session'
: 'Todo sent to new session'
? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession')
: t('rightSidebar.contextNotesTodo.toast.sentToNewSession')
);
setPendingSendTarget(null);
onActionComplete?.();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error('Failed to send todo', description ? { description } : undefined);
toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined);
} finally {
setIsSendDialogSubmitting(false);
setSendingTodoId(null);
}
},
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession]
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t]
);
const planFileInputRef = React.useRef<HTMLInputElement | null>(null);
@@ -420,7 +427,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
try {
const ok = await deleteProjectPlanFile(projectRef, planId);
if (!ok) {
toast.error('Failed to delete plan');
toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed'));
return;
}
setPlans((previous) => previous.filter((entry) => entry.id !== planId));
@@ -431,7 +438,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
setDeletingPlanId(null);
}
},
[deletingPlanId, projectRef]
[deletingPlanId, projectRef, t]
);
const handleTriggerUploadPlan = React.useCallback(() => {
@@ -450,27 +457,27 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
try {
const text = await file.text();
if (!text.trim()) {
toast.error('Plan file is empty');
toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty'));
return;
}
const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim();
const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle);
if (!created) {
toast.error('Failed to import plan');
toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: projectRef.id },
}));
toast.success('Plan imported');
toast.success(t('rightSidebar.contextNotesTodo.toast.planImported'));
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error('Failed to read plan file', description ? { description } : undefined);
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
} finally {
setIsImportingPlan(false);
}
},
[projectRef]
[projectRef, t]
);
const handleOpenPlan = React.useCallback(
@@ -493,7 +500,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
if (!projectRef) {
return (
<div className={cn('w-full min-w-0 p-3', className)}>
<p className="typography-meta text-muted-foreground">Select a project to add notes and todos.</p>
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.empty.selectProject')}
</p>
</div>
);
}
@@ -503,7 +512,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<h3 className="min-w-0 truncate typography-ui-label font-semibold text-foreground" title={projectRef.path}>
Quick notes - {projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path}
{t('rightSidebar.contextNotesTodo.notes.title', {
project: projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path,
})}
</h3>
<span className="typography-meta text-muted-foreground">{notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH}</span>
</div>
@@ -511,7 +522,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH))}
onBlur={handleNotesBlur}
placeholder="Capture context, reminders, or links"
placeholder={t('rightSidebar.contextNotesTodo.notes.placeholder')}
className="min-h-28 max-h-80 resize-none"
useScrollShadow
scrollShadowSize={56}
@@ -522,15 +533,21 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Todo</h3>
<span className="typography-meta text-muted-foreground">{todos.length} item{todos.length === 1 ? '' : 's'}</span>
<h3 className="typography-ui-label font-semibold text-foreground">
{t('rightSidebar.contextNotesTodo.todo.title')}
</h3>
<span className="typography-meta text-muted-foreground">
{todos.length === 1
? t('rightSidebar.contextNotesTodo.todo.itemsSingle', { count: todos.length })
: t('rightSidebar.contextNotesTodo.todo.itemsPlural', { count: todos.length })}
</span>
<button
type="button"
onClick={handleClearCompletedTodos}
disabled={isLoading || completedTodoCount === 0}
className="typography-meta rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
>
Clear completed
{t('rightSidebar.contextNotesTodo.todo.clearCompleted')}
</button>
</div>
<span className="typography-meta text-muted-foreground">{todoInputValue.length}/{OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH}</span>
@@ -546,7 +563,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
handleAddTodo();
}
}}
placeholder="Add a todo"
placeholder={t('rightSidebar.contextNotesTodo.todo.inputPlaceholder')}
disabled={isLoading}
className="h-8"
/>
@@ -555,7 +572,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
onClick={handleAddTodo}
disabled={isLoading || todoInputValue.trim().length === 0}
className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Add todo"
aria-label={t('rightSidebar.contextNotesTodo.todo.addAria')}
title={t('rightSidebar.contextNotesTodo.todo.addAria')}
>
<RiAddLine className="h-4 w-4" />
</button>
@@ -563,7 +581,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
{todos.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">No todos yet. Add a small checklist for this project.</p>
<p className="px-3 py-3 typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.todo.empty')}
</p>
) : (
<ul className="divide-y divide-border/50">
{todos.map((todo) => {
@@ -574,7 +594,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={`Mark "${todo.text}" complete`}
ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })}
/>
</div>
<button
@@ -587,7 +607,11 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={isExpandedTodo ? `Collapse todo "${todo.text}"` : `Expand todo "${todo.text}"`}
aria-label={
isExpandedTodo
? t('rightSidebar.contextNotesTodo.todo.actions.collapse', { text: todo.text })
: t('rightSidebar.contextNotesTodo.todo.actions.expand', { text: todo.text })
}
>
{todo.text}
</button>
@@ -596,7 +620,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Delete "${todo.text}"`}
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
@@ -606,23 +631,24 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Send "${todo.text}"`}
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
>
<RiSendPlaneLine className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
Send to current session
{t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.id, todo.text)}>
Send to new session
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
Send to new worktree session
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -638,8 +664,14 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Plans</h3>
<span className="typography-meta text-muted-foreground">{plans.length} file{plans.length === 1 ? '' : 's'}</span>
<h3 className="typography-ui-label font-semibold text-foreground">
{t('rightSidebar.contextNotesTodo.plans.title')}
</h3>
<span className="typography-meta text-muted-foreground">
{plans.length === 1
? t('rightSidebar.contextNotesTodo.plans.filesSingle', { count: plans.length })
: t('rightSidebar.contextNotesTodo.plans.filesPlural', { count: plans.length })}
</span>
</div>
<input
ref={planFileInputRef}
@@ -657,8 +689,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
onClick={handleTriggerUploadPlan}
disabled={!projectRef || isImportingPlan}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Import plan from file"
title="Import plan from file"
aria-label={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
title={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
@@ -666,7 +698,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
{plans.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">No saved plans yet.</p>
<p className="px-3 py-3 typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.plans.empty')}
</p>
) : (
<ul className="divide-y divide-border/50">
{plans.map((plan) => (
@@ -686,8 +720,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
onClick={() => void handleDeletePlan(plan.id)}
disabled={deletingPlanId === plan.id}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Delete plan "${plan.title}"`}
title="Delete plan"
title={t('rightSidebar.contextNotesTodo.plans.deletePlan')}
aria-label={t('rightSidebar.contextNotesTodo.plans.deletePlanWithTitle', { title: plan.title })}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n';
type SaveProjectPlanDialogProps = {
open: boolean;
@@ -20,6 +21,7 @@ type SaveProjectPlanDialogProps = {
};
export function SaveProjectPlanDialog(props: SaveProjectPlanDialogProps) {
const { t } = useI18n();
const { open, onOpenChange, initialTitle, sourceText, saving = false, onSave } = props;
const [title, setTitle] = React.useState(initialTitle);
@@ -35,24 +37,24 @@ export function SaveProjectPlanDialog(props: SaveProjectPlanDialogProps) {
<Dialog open={open} onOpenChange={(nextOpen) => { if (!saving) onOpenChange(nextOpen); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Save as plan</DialogTitle>
<DialogDescription>Choose a title for the saved markdown plan file.</DialogDescription>
<DialogTitle>{t('saveProjectPlanDialog.title')}</DialogTitle>
<DialogDescription>{t('saveProjectPlanDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">Title</label>
<label className="typography-ui-label font-medium text-foreground">{t('saveProjectPlanDialog.field.title')}</label>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Plan title"
placeholder={t('saveProjectPlanDialog.field.titlePlaceholder')}
autoFocus
disabled={saving}
/>
</div>
<div className="space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">Content preview</label>
<label className="typography-ui-label font-medium text-foreground">{t('saveProjectPlanDialog.field.contentPreview')}</label>
<div className="max-h-40 overflow-auto rounded-lg border border-border/70 bg-[var(--surface-subtle)] px-3 py-2 typography-meta text-foreground">
{sourceText}
</div>
@@ -60,9 +62,9 @@ export function SaveProjectPlanDialog(props: SaveProjectPlanDialogProps) {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>Cancel</Button>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t('saveProjectPlanDialog.actions.cancel')}</Button>
<Button onClick={() => void onSave(trimmedTitle)} disabled={!trimmedTitle || saving}>
{saving ? 'Saving...' : 'Save'}
{saving ? t('saveProjectPlanDialog.actions.saving') : t('saveProjectPlanDialog.actions.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -18,6 +18,7 @@ import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/ch
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import type { ScheduledTask } from '@/lib/scheduledTasksApi';
import { useI18n } from '@/lib/i18n';
const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
@@ -71,12 +72,12 @@ const formatLocalDateISO = (date: Date): string => {
return `${year}-${month}-${day}`;
};
const formatDateLabel = (isoDate: string): string => {
const formatDateLabel = (isoDate: string, fallbackLabel: string, locale: string): string => {
const date = parseISODateToLocal(isoDate);
if (!date) {
return 'Select date';
return fallbackLabel;
}
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
@@ -236,6 +237,11 @@ interface TimePillProps {
value: string;
onChange: (next: string) => void;
use24Hour: boolean;
hourAriaLabel: string;
minuteAriaLabel: string;
periodAriaLabel: string;
amLabel: string;
pmLabel: string;
}
const FieldLabel: React.FC<{
@@ -251,7 +257,16 @@ const FieldLabel: React.FC<{
</div>
);
const TimePill: React.FC<TimePillProps> = ({ value, onChange, use24Hour }) => {
const TimePill: React.FC<TimePillProps> = ({
value,
onChange,
use24Hour,
hourAriaLabel,
minuteAriaLabel,
periodAriaLabel,
amLabel,
pmLabel,
}) => {
const parts = React.useMemo(() => parse24hTime(value), [value]);
const hourRef = React.useRef<HTMLInputElement>(null);
const minuteRef = React.useRef<HTMLInputElement>(null);
@@ -383,7 +398,7 @@ const TimePill: React.FC<TimePillProps> = ({ value, onChange, use24Hour }) => {
onFocus={() => setHourDraft('')}
onBlur={commitHour}
maxLength={2}
aria-label="Hours"
aria-label={hourAriaLabel}
className="h-7 w-7 shrink-0 rounded-sm bg-transparent text-center font-mono text-sm tabular-nums text-foreground outline-none caret-transparent focus:bg-interactive-hover"
/>
<span className="font-mono text-sm text-muted-foreground">:</span>
@@ -396,20 +411,20 @@ const TimePill: React.FC<TimePillProps> = ({ value, onChange, use24Hour }) => {
onFocus={() => setMinuteDraft('')}
onBlur={commitMinute}
maxLength={2}
aria-label="Minutes"
aria-label={minuteAriaLabel}
className="h-7 w-7 shrink-0 rounded-sm bg-transparent text-center font-mono text-sm tabular-nums text-foreground outline-none caret-transparent focus:bg-interactive-hover"
/>
{!use24Hour ? (
<Select value={parts.meridiem} onValueChange={(next) => setPeriod(next as 'AM' | 'PM')}>
<SelectTrigger
aria-label="Period"
aria-label={periodAriaLabel}
className="ml-1 h-7 w-fit border-0 bg-transparent pl-2 pr-1 shadow-none hover:bg-interactive-hover focus:ring-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
<SelectItem value="AM">{amLabel}</SelectItem>
<SelectItem value="PM">{pmLabel}</SelectItem>
</SelectContent>
</Select>
) : null}
@@ -529,37 +544,37 @@ const toDraft = (
};
};
const validateDraft = (draft: ScheduledTaskDraft): string | null => {
const validateDraft = (draft: ScheduledTaskDraft, t: ReturnType<typeof useI18n>['t']): string | null => {
if (!draft.name.trim()) {
return 'Task name is required';
return t('sessions.scheduledTasks.editor.validation.taskNameRequired');
}
if (!draft.execution.prompt.trim()) {
return 'Prompt is required';
return t('sessions.scheduledTasks.editor.validation.promptRequired');
}
if (!draft.execution.providerID.trim() || !draft.execution.modelID.trim()) {
return 'Model is required';
return t('sessions.scheduledTasks.editor.validation.modelRequired');
}
if (draft.schedule.kind === 'once') {
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.schedule.onceDate)) {
return 'Date must use YYYY-MM-DD';
return t('sessions.scheduledTasks.editor.validation.dateFormat');
}
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(draft.schedule.onceTime)) {
return 'Time must use HH:mm';
return t('sessions.scheduledTasks.editor.validation.timeFormat');
}
} else {
const validTimes = draft.schedule.times.filter((value) => /^([01]\d|2[0-3]):([0-5]\d)$/.test(value));
if (validTimes.length === 0) {
return 'Add at least one valid time';
return t('sessions.scheduledTasks.editor.validation.atLeastOneTime');
}
}
if (draft.schedule.kind === 'weekly' && draft.schedule.weekdays.length === 0) {
return 'Select at least one weekday';
return t('sessions.scheduledTasks.editor.validation.atLeastOneWeekday');
}
if (!draft.schedule.timezone.trim()) {
return 'Timezone is required';
return t('sessions.scheduledTasks.editor.validation.timezoneRequired');
}
return null;
@@ -577,6 +592,7 @@ export function ScheduledTaskEditorDialog(props: {
onSave: (draft: Partial<ScheduledTask>) => Promise<void>;
}) {
const { open, task, onOpenChange, onSave } = props;
const { t, locale } = useI18n();
const loadProviders = useConfigStore((state) => state.loadProviders);
const loadAgents = useConfigStore((state) => state.loadAgents);
const providers = useConfigStore((state) => state.providers);
@@ -610,12 +626,6 @@ export function ScheduledTaskEditorDialog(props: {
const promptTextareaRef = React.useRef<HTMLTextAreaElement>(null);
const mentionRef = React.useRef<FileMentionHandle>(null);
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const locale = React.useMemo(() => {
if (typeof navigator !== 'undefined' && navigator.language) {
return navigator.language;
}
return Intl.DateTimeFormat().resolvedOptions().locale || 'en-US';
}, []);
const localeUse24Hour = React.useMemo(() => getUses24Hour(locale), [locale]);
const localeWeekStartsOn = React.useMemo(() => getWeekStartsOn(locale), [locale]);
const use24Hour = React.useMemo(() => {
@@ -783,10 +793,10 @@ export function ScheduledTaskEditorDialog(props: {
return null;
}
if (isSameCalendarDay(selectedDate, todayDate)) {
return 'Today';
return t('sessions.scheduledTasks.editor.date.today');
}
return new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(selectedDate);
}, [draft.schedule.onceDate, todayDate]);
return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(selectedDate);
}, [draft.schedule.onceDate, locale, t, todayDate]);
const isAtCurrentMonth = React.useMemo(
() => startOfMonth(calendarMonth).getTime() <= currentMonthStart.getTime(),
[calendarMonth, currentMonthStart],
@@ -942,7 +952,7 @@ export function ScheduledTaskEditorDialog(props: {
}, [showCommandAutocomplete, showFileMention]);
const handleSubmit = React.useCallback(async () => {
const validationError = validateDraft(draft);
const validationError = validateDraft(draft, t);
if (validationError) {
toast.error(validationError);
return;
@@ -981,42 +991,42 @@ export function ScheduledTaskEditorDialog(props: {
await onSave(payload);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save task');
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.editor.toast.saveFailed'));
} finally {
setSaving(false);
}
}, [draft, onOpenChange, onSave]);
}, [draft, onOpenChange, onSave, t]);
const descriptionId = React.useId();
const hasOpenFloatingMenu = React.useCallback(() => {
if (typeof document === 'undefined') return false;
return Boolean(
document.querySelector(
'[data-slot="dropdown-menu-content"][data-state="open"], [data-slot="select-content"][data-state="open"]'
'[data-slot="dropdown-menu-content"], [data-slot="select-content"]'
)
);
}, []);
const title = task ? 'Edit scheduled task' : 'New scheduled task';
const description = 'Configure a server-side task that creates a new session and sends a prompt.';
const title = task ? t('sessions.scheduledTasks.editor.title.edit') : t('sessions.scheduledTasks.editor.title.new');
const description = t('sessions.scheduledTasks.editor.description');
const formBody = (
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<FieldLabel htmlFor="sched-name" required>Task name</FieldLabel>
<FieldLabel htmlFor="sched-name" required>{t('sessions.scheduledTasks.editor.taskName.label')}</FieldLabel>
<Input
id="sched-name"
value={draft.name}
onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Daily sync"
placeholder={t('sessions.scheduledTasks.editor.taskName.placeholder')}
maxLength={80}
className="w-full sm:max-w-[220px]"
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Schedule type</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.scheduleType.label')}</FieldLabel>
<Select
value={draft.schedule.kind}
onValueChange={(value: 'daily' | 'weekly' | 'once') => {
@@ -1026,11 +1036,19 @@ export function ScheduledTaskEditorDialog(props: {
}));
}}
>
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-fit max-w-full">
<SelectValue>
{(value) => value === 'daily'
? t('sessions.scheduledTasks.editor.scheduleType.daily')
: value === 'weekly'
? t('sessions.scheduledTasks.editor.scheduleType.weekly')
: t('sessions.scheduledTasks.editor.scheduleType.once')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="once">One-time</SelectItem>
<SelectItem value="daily">{t('sessions.scheduledTasks.editor.scheduleType.daily')}</SelectItem>
<SelectItem value="weekly">{t('sessions.scheduledTasks.editor.scheduleType.weekly')}</SelectItem>
<SelectItem value="once">{t('sessions.scheduledTasks.editor.scheduleType.once')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1040,7 +1058,7 @@ export function ScheduledTaskEditorDialog(props: {
{draft.schedule.kind === 'once' ? (
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="flex flex-col gap-1" ref={datePickerRef}>
<FieldLabel>Date</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.date.label')}</FieldLabel>
<div className="relative">
<button
type="button"
@@ -1049,7 +1067,7 @@ export function ScheduledTaskEditorDialog(props: {
>
<span className="inline-flex items-center gap-2">
<RiCalendarLine className="h-4 w-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground">{formatDateLabel(draft.schedule.onceDate)}</span>
<span className="typography-ui-label text-foreground">{formatDateLabel(draft.schedule.onceDate, t('sessions.scheduledTasks.editor.date.placeholder'), locale)}</span>
</span>
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
</button>
@@ -1061,19 +1079,19 @@ export function ScheduledTaskEditorDialog(props: {
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-interactive-hover disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setCalendarMonth((prev) => shiftMonth(prev, -1))}
aria-label="Previous month"
aria-label={t('sessions.scheduledTasks.editor.date.previousMonth')}
disabled={isAtCurrentMonth}
>
<RiArrowLeftSLine className="h-4 w-4" />
</button>
<div className="typography-ui-label text-foreground">
{new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(calendarMonth)}
{new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(calendarMonth)}
</div>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-interactive-hover"
onClick={() => setCalendarMonth((prev) => shiftMonth(prev, 1))}
aria-label="Next month"
aria-label={t('sessions.scheduledTasks.editor.date.nextMonth')}
>
<RiArrowRightSLine className="h-4 w-4" />
</button>
@@ -1138,7 +1156,7 @@ export function ScheduledTaskEditorDialog(props: {
setCalendarMonth(new Date(todayDate.getFullYear(), todayDate.getMonth(), 1));
}}
>
Jump to today
{t('sessions.scheduledTasks.editor.date.jumpToToday')}
</Button>
</div>
</div>
@@ -1147,10 +1165,15 @@ export function ScheduledTaskEditorDialog(props: {
</div>
<div className="flex min-w-0 flex-col gap-1">
<FieldLabel>Time</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.time.label')}</FieldLabel>
<TimePill
value={draft.schedule.onceTime}
use24Hour={use24Hour}
hourAriaLabel={t('sessions.scheduledTasks.editor.time.hourAria')}
minuteAriaLabel={t('sessions.scheduledTasks.editor.time.minuteAria')}
periodAriaLabel={t('sessions.scheduledTasks.editor.time.periodAria')}
amLabel={t('sessions.scheduledTasks.editor.time.period.am')}
pmLabel={t('sessions.scheduledTasks.editor.time.period.pm')}
onChange={(next) => setDraft((prev) => ({
...prev,
schedule: { ...prev.schedule, onceTime: next },
@@ -1158,7 +1181,7 @@ export function ScheduledTaskEditorDialog(props: {
/>
<div className="mt-2 flex flex-col gap-1">
<FieldLabel>Timezone</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.timezone.label')}</FieldLabel>
<Select
value={draft.schedule.timezone}
onValueChange={(timezone) => {
@@ -1182,7 +1205,7 @@ export function ScheduledTaskEditorDialog(props: {
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
{draft.schedule.kind === 'weekly' ? (
<div className="flex flex-col gap-1 sm:col-span-2">
<FieldLabel>Weekdays</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.weekdays.label')}</FieldLabel>
<div className="flex flex-wrap gap-x-3 gap-y-2">
{orderedWeekdays.map((weekday) => {
const checked = draft.schedule.weekdays.includes(weekday.value);
@@ -1207,13 +1230,18 @@ export function ScheduledTaskEditorDialog(props: {
) : null}
<div className="flex flex-col gap-2">
<FieldLabel>Times</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.times.label')}</FieldLabel>
<div className="flex flex-col gap-2">
{draft.schedule.times.map((time, index) => (
<div key={index} className="flex items-center gap-2">
<TimePill
value={time}
use24Hour={use24Hour}
hourAriaLabel={t('sessions.scheduledTasks.editor.time.hourAria')}
minuteAriaLabel={t('sessions.scheduledTasks.editor.time.minuteAria')}
periodAriaLabel={t('sessions.scheduledTasks.editor.time.periodAria')}
amLabel={t('sessions.scheduledTasks.editor.time.period.am')}
pmLabel={t('sessions.scheduledTasks.editor.time.period.pm')}
onChange={(next) => updateTimeAt(index, next)}
/>
{draft.schedule.times.length > 1 ? (
@@ -1222,7 +1250,7 @@ export function ScheduledTaskEditorDialog(props: {
size="icon"
variant="ghost"
onClick={() => removeTimeAt(index)}
aria-label="Remove time"
aria-label={t('sessions.scheduledTasks.editor.times.removeAria')}
>
<RiCloseLine className="h-4 w-4" />
</Button>
@@ -1232,13 +1260,13 @@ export function ScheduledTaskEditorDialog(props: {
</div>
<div>
<Button type="button" size="sm" variant="outline" onClick={addTime}>
<RiAddLine className="mr-1 h-4 w-4" /> Add time
<RiAddLine className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.editor.times.add')}
</Button>
</div>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Timezone</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.timezone.label')}</FieldLabel>
<Select
value={draft.schedule.timezone}
onValueChange={(timezone) => {
@@ -1261,7 +1289,7 @@ export function ScheduledTaskEditorDialog(props: {
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<FieldLabel required>Model</FieldLabel>
<FieldLabel required>{t('sessions.scheduledTasks.editor.model.label')}</FieldLabel>
<ModelSelector
providerId={draft.execution.providerID}
modelId={draft.execution.modelID}
@@ -1280,7 +1308,7 @@ export function ScheduledTaskEditorDialog(props: {
</div>
<div className="flex min-w-0 flex-col gap-1">
<FieldLabel>Thinking level</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.thinkingLevel.label')}</FieldLabel>
<Select
value={selectedVariantValue}
disabled={!hasVariantOptions}
@@ -1294,9 +1322,15 @@ export function ScheduledTaskEditorDialog(props: {
}));
}}
>
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-fit max-w-full">
<SelectValue>
{(value) => value === '__default'
? t('sessions.scheduledTasks.editor.thinkingLevel.default')
: value}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__default">Default</SelectItem>
<SelectItem value="__default">{t('sessions.scheduledTasks.editor.thinkingLevel.default')}</SelectItem>
{variantOptions.map((variant) => (
<SelectItem key={variant} value={variant}>{variant}</SelectItem>
))}
@@ -1306,7 +1340,7 @@ export function ScheduledTaskEditorDialog(props: {
</div>
<div className="flex min-w-0 flex-col gap-1">
<FieldLabel>Agent</FieldLabel>
<FieldLabel>{t('sessions.scheduledTasks.editor.agent.label')}</FieldLabel>
<AgentSelector
agentName={draft.execution.agent}
filter={(agent) => isPrimaryMode(agent.mode)}
@@ -1321,7 +1355,7 @@ export function ScheduledTaskEditorDialog(props: {
</div>
<div className="flex flex-col gap-1">
<FieldLabel htmlFor="sched-prompt" required>Prompt</FieldLabel>
<FieldLabel htmlFor="sched-prompt" required>{t('sessions.scheduledTasks.editor.prompt.label')}</FieldLabel>
<div className="relative">
<Textarea
id="sched-prompt"
@@ -1335,7 +1369,7 @@ export function ScheduledTaskEditorDialog(props: {
}}
onKeyDown={handlePromptKeyDown}
rows={8}
placeholder="Summarize open tasks and propose next actions"
placeholder={t('sessions.scheduledTasks.editor.prompt.placeholder')}
className="typography-meta min-h-[120px] max-h-[300px] resize-none overflow-y-auto"
/>
@@ -1382,17 +1416,17 @@ export function ScheduledTaskEditorDialog(props: {
<Checkbox
checked={draft.enabled}
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
ariaLabel="Enable task"
ariaLabel={t('sessions.scheduledTasks.editor.enabled.aria')}
/>
<span className="typography-meta">Enabled</span>
<span className="typography-meta">{t('sessions.scheduledTasks.editor.enabled.label')}</span>
</label>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
{t('sessions.scheduledTasks.editor.actions.cancel')}
</Button>
<Button type="button" size="sm" onClick={handleSubmit} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
{saving ? t('sessions.scheduledTasks.editor.actions.saving') : t('sessions.scheduledTasks.editor.actions.save')}
</Button>
</div>
</div>
@@ -1422,14 +1456,19 @@ export function ScheduledTaskEditorDialog(props: {
}
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Root
open={open}
onOpenChange={(next) => {
if (!next && hasOpenFloatingMenu()) {
return;
}
onOpenChange(next);
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 dark:bg-black/75" />
<DialogPrimitive.Content
aria-describedby={descriptionId}
onInteractOutside={(event) => {
if (hasOpenFloatingMenu()) event.preventDefault();
}}
className={cn(
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-[90vw] max-w-[720px] h-[680px] max-h-[85vh]',
@@ -1441,7 +1480,7 @@ export function ScheduledTaskEditorDialog(props: {
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Close"
aria-label={t('sessions.scheduledTasks.editor.actions.closeAria')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiCloseLine className="h-5 w-5" />
@@ -32,6 +32,7 @@ import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn, formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import type { ProjectEntry } from '@/lib/api/types';
import {
deleteScheduledTask,
@@ -43,8 +44,6 @@ import {
} from '@/lib/scheduledTasksApi';
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const scheduleTimes = (task: ScheduledTask): string[] => {
const raw = Array.isArray(task.schedule.times)
? task.schedule.times
@@ -53,27 +52,63 @@ const scheduleTimes = (task: ScheduledTask): string[] => {
return Array.from(new Set(valid)).sort((a, b) => a.localeCompare(b));
};
const formatSchedule = (task: ScheduledTask): string => {
const formatSchedule = (task: ScheduledTask, t: ReturnType<typeof useI18n>['t']): string => {
const timesLabel = scheduleTimes(task).join(', ') || '--:--';
const formatWeekday = (value: number) => {
if (value === 0) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sun');
if (value === 1) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.mon');
if (value === 2) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.tue');
if (value === 3) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.wed');
if (value === 4) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.thu');
if (value === 5) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.fri');
if (value === 6) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sat');
return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.unknown');
};
if (task.schedule.kind === 'daily') {
return `Daily ${timesLabel}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.dailyWithTimezone', {
time: timesLabel,
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.daily', { time: timesLabel });
}
if (task.schedule.kind === 'weekly') {
const days = Array.isArray(task.schedule.weekdays)
? task.schedule.weekdays.map((value) => WEEKDAY_NAMES[value] || '?').join(', ')
? task.schedule.weekdays.map((value) => formatWeekday(value)).join(', ')
: '';
return `Weekly ${days} ${timesLabel}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.weeklyWithTimezone', {
days,
time: timesLabel,
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.weekly', { days, time: timesLabel });
}
if (task.schedule.kind === 'once') {
const date = typeof task.schedule.date === 'string' && task.schedule.date.trim().length > 0
? task.schedule.date
: 'Unknown date';
: t('sessions.scheduledTasks.dialog.schedule.unknownDate');
const time = typeof task.schedule.time === 'string' && task.schedule.time.trim().length > 0
? task.schedule.time
: '--:--';
return `One-time ${date} ${time}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.onceWithTimezone', {
date,
time,
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.once', { date, time });
}
return `Cron: ${task.schedule.cron || ''}${task.schedule.timezone ? ` (${task.schedule.timezone})` : ''}`;
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.cronWithTimezone', {
cron: task.schedule.cron || '',
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.cron', { cron: task.schedule.cron || '' });
};
const formatClockTime = (value?: number): string => {
@@ -83,7 +118,7 @@ const formatClockTime = (value?: number): string => {
return new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
};
const formatRelativeTime = (value?: number): string => {
const formatRelativeTime = (value: number | undefined, t: ReturnType<typeof useI18n>['t']): string => {
if (!value || !Number.isFinite(value)) {
return '';
}
@@ -94,22 +129,28 @@ const formatRelativeTime = (value?: number): string => {
const day = 24 * hour;
const future = diff >= 0;
if (abs < minute) {
return future ? 'in <1m' : 'just now';
return future ? t('sessions.scheduledTasks.dialog.relativeTime.inLessThanOneMinute') : t('sessions.scheduledTasks.dialog.relativeTime.justNow');
}
if (abs < hour) {
const m = Math.round(abs / minute);
return future ? `in ${m}m` : `${m}m ago`;
return future
? t('sessions.scheduledTasks.dialog.relativeTime.inMinutes', { count: m })
: t('sessions.scheduledTasks.dialog.relativeTime.minutesAgo', { count: m });
}
if (abs < day) {
const h = Math.floor(abs / hour);
const m = Math.round((abs % hour) / minute);
const body = m > 0 ? `${h}h ${m}m` : `${h}h`;
return future ? `in ${body}` : `${body} ago`;
return future
? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body })
: t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body });
}
const d = Math.floor(abs / day);
const h = Math.round((abs % day) / hour);
const body = h > 0 ? `${d}d ${h}h` : `${d}d`;
return future ? `in ${body}` : `${body} ago`;
return future
? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body })
: t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body });
};
type StatusTone = 'success' | 'error' | 'warning' | 'muted';
@@ -118,15 +159,14 @@ const STATUS_META: Record<
ScheduledTaskStatus,
{
tone: StatusTone;
label: string;
Icon: React.ComponentType<{ className?: string }>;
spin?: boolean;
}
> = {
success: { tone: 'success', label: 'Success', Icon: RiCheckboxCircleLine },
error: { tone: 'error', label: 'Error', Icon: RiErrorWarningLine },
running: { tone: 'warning', label: 'Running', Icon: RiLoader4Line, spin: true },
idle: { tone: 'muted', label: 'Idle', Icon: RiPulseLine },
success: { tone: 'success', Icon: RiCheckboxCircleLine },
error: { tone: 'error', Icon: RiErrorWarningLine },
running: { tone: 'warning', Icon: RiLoader4Line, spin: true },
idle: { tone: 'muted', Icon: RiPulseLine },
};
const toneStyle = (tone: StatusTone): React.CSSProperties => {
@@ -141,6 +181,7 @@ const toneStyle = (tone: StatusTone): React.CSSProperties => {
};
export function ScheduledTasksDialog() {
const { t } = useI18n();
const open = useUIStore((state) => state.isScheduledTasksDialogOpen);
const setOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
const isMobile = useUIStore((state) => state.isMobile);
@@ -214,7 +255,7 @@ export function ScheduledTasksDialog() {
});
setTasks(nextTasks);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load scheduled tasks');
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.loadFailed'));
if (!options?.silent) {
setTasks([]);
}
@@ -223,7 +264,7 @@ export function ScheduledTasksDialog() {
setLoading(false);
}
}
}, []);
}, [t]);
React.useEffect(() => {
if (!open) {
@@ -267,12 +308,12 @@ export function ScheduledTasksDialog() {
const handleSaveTask = React.useCallback(async (taskDraft: Partial<ScheduledTask>) => {
if (!selectedProjectID) {
throw new Error('Choose a project first');
throw new Error(t('sessions.scheduledTasks.dialog.error.chooseProjectFirst'));
}
await upsertScheduledTask(selectedProjectID, taskDraft);
await reloadTasks(selectedProjectID);
toast.success('Scheduled task saved');
}, [selectedProjectID, reloadTasks]);
toast.success(t('sessions.scheduledTasks.dialog.toast.saved'));
}, [selectedProjectID, reloadTasks, t]);
const handleToggleEnabled = React.useCallback(async (task: ScheduledTask, enabled: boolean) => {
if (!selectedProjectID) {
@@ -287,18 +328,18 @@ export function ScheduledTasksDialog() {
});
await reloadTasks(selectedProjectID, { silent: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update task');
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.updateFailed'));
await reloadTasks(selectedProjectID, { silent: true });
} finally {
setMutatingTaskID(null);
}
}, [selectedProjectID, reloadTasks]);
}, [selectedProjectID, reloadTasks, t]);
const handleDeleteTask = React.useCallback(async (task: ScheduledTask) => {
if (!selectedProjectID) {
return;
}
const confirmed = window.confirm(`Delete scheduled task "${task.name}"?`);
const confirmed = window.confirm(t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
if (!confirmed) {
return;
}
@@ -307,13 +348,13 @@ export function ScheduledTasksDialog() {
try {
await deleteScheduledTask(selectedProjectID, task.id);
await reloadTasks(selectedProjectID, { silent: true });
toast.success('Scheduled task deleted');
toast.success(t('sessions.scheduledTasks.dialog.toast.deleted'));
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete task');
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.deleteFailed'));
} finally {
setMutatingTaskID(null);
}
}, [selectedProjectID, reloadTasks]);
}, [selectedProjectID, reloadTasks, t]);
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
if (!selectedProjectID) {
@@ -326,17 +367,17 @@ export function ScheduledTasksDialog() {
reloadTasks(selectedProjectID, { silent: true }),
refreshGlobalSessions(),
]);
toast.success('Task started');
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to run task');
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
} finally {
setMutatingTaskID(null);
}
}, [selectedProjectID, reloadTasks]);
}, [selectedProjectID, reloadTasks, t]);
const projectSelector = (
<div className="flex flex-col items-start gap-1">
<span className="typography-meta text-muted-foreground">Project</span>
<span className="typography-meta text-muted-foreground">{t('sessions.scheduledTasks.dialog.project.label')}</span>
<Select
value={selectedProjectID || '__none'}
onValueChange={(value) => {
@@ -353,11 +394,11 @@ export function ScheduledTasksDialog() {
{selectedProject ? (
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
) : (
<SelectValue placeholder="Select project" />
<SelectValue placeholder={t('sessions.scheduledTasks.dialog.project.placeholder')} />
)}
</SelectTrigger>
<SelectContent>
{projects.length === 0 ? <SelectItem value="__none">No projects</SelectItem> : null}
{projects.length === 0 ? <SelectItem value="__none">{t('sessions.scheduledTasks.dialog.project.empty')}</SelectItem> : null}
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{renderProjectLabel(project)}
@@ -379,7 +420,7 @@ export function ScheduledTasksDialog() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{projectSelector}
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
<RiAddLine className="mr-1 h-4 w-4" /> New task
<RiAddLine className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
</Button>
</div>
) : (
@@ -388,11 +429,11 @@ export function ScheduledTasksDialog() {
{loading ? (
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" /> Loading tasks...
<RiLoader4Line className="h-4 w-4 animate-spin" /> {t('sessions.scheduledTasks.dialog.loading')}
</div>
) : tasks.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-4 typography-meta text-muted-foreground">
{selectedProjectID ? 'No scheduled tasks yet.' : 'Select a project to manage scheduled tasks.'}
{selectedProjectID ? t('sessions.scheduledTasks.dialog.empty.noTasks') : t('sessions.scheduledTasks.dialog.empty.selectProject')}
</div>
) : (
<div className="space-y-2.5">
@@ -400,6 +441,13 @@ export function ScheduledTasksDialog() {
const isBusy = mutatingTaskID === task.id;
const status = (task.state?.lastStatus || 'idle') as ScheduledTaskStatus;
const meta = STATUS_META[status];
const statusLabel = status === 'success'
? t('sessions.scheduledTasks.dialog.status.success')
: status === 'error'
? t('sessions.scheduledTasks.dialog.status.error')
: status === 'running'
? t('sessions.scheduledTasks.dialog.status.running')
: t('sessions.scheduledTasks.dialog.status.idle');
const nextAt = task.state?.nextRunAt;
const lastAt = task.state?.lastRunAt;
@@ -416,17 +464,17 @@ export function ScheduledTasksDialog() {
{task.name}
</div>
<div className="typography-micro truncate text-muted-foreground">
{formatSchedule(task)}
{formatSchedule(task, t)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<RiTimerLine className="h-3.5 w-3.5" />
<span className="font-medium text-foreground">Next</span>
<span className="font-medium text-foreground">{t('sessions.scheduledTasks.dialog.nextRun.label')}</span>
{nextAt ? (
<>
<span className="text-foreground">{formatRelativeTime(nextAt)}</span>
<span className="text-foreground">{formatRelativeTime(nextAt, t)}</span>
<span className="text-muted-foreground/50">·</span>
<span>{formatClockTime(nextAt)}</span>
</>
@@ -436,14 +484,14 @@ export function ScheduledTasksDialog() {
</span>
<span className="inline-flex items-center gap-1.5">
<RiHistoryLine className="h-3.5 w-3.5" />
<span className="font-medium text-foreground">Last run</span>
<span className="font-medium text-foreground">{t('sessions.scheduledTasks.dialog.lastRun.label')}</span>
{status === 'running' ? (
<span
className="inline-flex items-center gap-1"
style={{ color: 'var(--status-warning)' }}
>
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
running now
{t('sessions.scheduledTasks.dialog.lastRun.runningNow')}
</span>
) : lastAt ? (
<>
@@ -453,14 +501,14 @@ export function ScheduledTasksDialog() {
style={{ color: `var(--status-${meta.tone})` }}
>
<meta.Icon className="h-3.5 w-3.5" />
{meta.label}
{statusLabel}
</span>
) : null}
<span className="text-muted-foreground/50">·</span>
<span>{formatRelativeTime(lastAt)}</span>
<span>{formatRelativeTime(lastAt, t)}</span>
</>
) : (
<span>never</span>
<span>{t('sessions.scheduledTasks.dialog.lastRun.never')}</span>
)}
</span>
</div>
@@ -486,10 +534,12 @@ export function ScheduledTasksDialog() {
<Checkbox
checked={task.enabled}
onChange={(enabled) => void handleToggleEnabled(task, enabled)}
ariaLabel={task.enabled ? `Pause ${task.name}` : `Enable ${task.name}`}
ariaLabel={task.enabled
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
disabled={isBusy}
/>
{task.enabled ? 'Enabled' : 'Paused'}
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
</label>
<div className="flex flex-wrap items-center gap-1.5">
@@ -499,7 +549,7 @@ export function ScheduledTasksDialog() {
onClick={() => void handleRunNow(task)}
disabled={isBusy}
>
<RiPlayLine className="h-4 w-4" /> run now
<RiPlayLine className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.runNow')}
</Button>
<Button
variant="outline"
@@ -509,16 +559,16 @@ export function ScheduledTasksDialog() {
setEditorOpen(true);
}}
disabled={isBusy}
aria-label={`Edit ${task.name}`}
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
>
<RiEdit2Line className="h-4 w-4" /> edit
<RiEdit2Line className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => void handleDeleteTask(task)}
disabled={isBusy}
aria-label={`Delete ${task.name}`}
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
@@ -537,17 +587,17 @@ export function ScheduledTasksDialog() {
{isMobile ? (
<MobileOverlayPanel
open={open}
title="Scheduled tasks"
title={t('sessions.scheduledTasks.dialog.title')}
onClose={() => setOpen(false)}
contentMaxHeightClassName="max-h-[min(80vh,640px)]"
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1 border-b border-border/40 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">Scheduled tasks</h2>
<h2 className="typography-ui-label font-semibold text-foreground">{t('sessions.scheduledTasks.dialog.title')}</h2>
{closeButton}
</div>
<p className="typography-micro text-muted-foreground">
Server-side tasks that create a new session and send a configured prompt.
{t('sessions.scheduledTasks.dialog.description')}
</p>
</div>
)}
@@ -557,7 +607,7 @@ export function ScheduledTasksDialog() {
onClick={openNewTaskEditor}
disabled={!selectedProjectID}
>
<RiAddLine className="mr-1 h-4 w-4" /> New task
<RiAddLine className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
</Button>
)}
>
@@ -567,8 +617,8 @@ export function ScheduledTasksDialog() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Scheduled tasks</DialogTitle>
<DialogDescription>Server-side tasks that create a new session and send a configured prompt.</DialogDescription>
<DialogTitle>{t('sessions.scheduledTasks.dialog.title')}</DialogTitle>
<DialogDescription>{t('sessions.scheduledTasks.dialog.description')}</DialogDescription>
</DialogHeader>
{tasksContent}
@@ -27,6 +27,7 @@ import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { isDesktopLocalOriginActive, isTauriShell } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { sessionEvents } from '@/lib/sessionEvents';
import { useI18n } from '@/lib/i18n';
const renderToastDescription = (text?: string) =>
text ? <span className="text-foreground/80 dark:text-foreground/70">{text}</span> : undefined;
@@ -50,6 +51,7 @@ type DeleteDialogState = {
};
export const SessionDialogs: React.FC = () => {
const { t } = useI18n();
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
@@ -125,7 +127,7 @@ export const SessionDialogs: React.FC = () => {
.then(async (result) => {
if (!result.success || !result.path) {
if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
toast.error(t('sessions.sidebar.sessionDialogs.directory.errorSelectTitle'), {
description: result.error,
});
}
@@ -134,22 +136,22 @@ export const SessionDialogs: React.FC = () => {
const accessResult = await startAccessing(result.path);
if (!accessResult.success) {
toast.error('Failed to open directory', {
description: accessResult.error || 'Desktop could not grant file access.',
toast.error(t('sessions.sidebar.sessionDialogs.directory.errorOpenTitle'), {
description: accessResult.error || t('sessions.sidebar.sessionDialogs.directory.errorOpenDescription'),
});
return;
}
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory path.',
toast.error(t('sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle'), {
description: t('sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription'),
});
}
})
.catch((error) => {
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
toast.error(t('sessions.sidebar.sessionDialogs.directory.errorSelectTitle'));
});
return;
}
@@ -162,6 +164,7 @@ export const SessionDialogs: React.FC = () => {
projects.length,
requestAccess,
startAccessing,
t,
]);
const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => {
@@ -192,9 +195,9 @@ export const SessionDialogs: React.FC = () => {
const target = payload.sessions[0];
const success = await deleteSession(target.id);
if (success) {
toast.success('Session deleted');
toast.success(t('sessions.sidebar.session.delete.success'));
} else {
toast.error('Failed to delete session');
toast.error(t('sessions.sidebar.session.delete.error'));
}
return;
}
@@ -204,21 +207,27 @@ export const SessionDialogs: React.FC = () => {
if (deletedIds.length > 0) {
const successDescription = failedIds.length > 0
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.`
? (failedIds.length === 1
? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length })
: t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length }))
: payload.dateLabel
? `Removed all sessions from ${payload.dateLabel}.`
? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: payload.dateLabel })
: undefined;
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }), {
description: renderToastDescription(successDescription),
});
}
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription('Please try again in a moment.'),
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }), {
description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')),
});
}
}, [deleteSession, deleteSessions]);
}, [deleteSession, deleteSessions, t]);
React.useEffect(() => {
return sessionEvents.onDeleteRequest((payload) => {
@@ -395,12 +404,12 @@ export const SessionDialogs: React.FC = () => {
return true;
} catch (error) {
toast.error('Failed to remove worktree', {
description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'),
toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), {
description: renderToastDescription(error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain')),
});
return false;
}
}, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget]);
}, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget, t]);
const handleConfirmDelete = React.useCallback(async () => {
if (!deleteDialog) {
@@ -420,8 +429,10 @@ export const SessionDialogs: React.FC = () => {
return;
}
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.';
toast.success('Worktree removed', {
const archiveNote = shouldRemoveRemote
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
: t('sessions.sidebar.sessionDialogs.worktree.removed');
toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), {
description: renderToastDescription(archiveNote),
});
closeDeleteDialog();
@@ -440,19 +451,23 @@ export const SessionDialogs: React.FC = () => {
deleteLocalBranch,
});
if (!success) {
toast.error(isWorktreeDelete ? 'Failed to archive session' : 'Failed to delete session');
toast.error(isWorktreeDelete
? t('sessions.sidebar.session.archive.error')
: t('sessions.sidebar.session.delete.error'));
setIsProcessingDelete(false);
return;
}
const archiveNote = !isWorktreeDelete && shouldArchive
? removeRemoteBranch
? 'Worktree and remote branch removed.'
: 'Attached worktree archived.'
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
: t('sessions.sidebar.sessionDialogs.worktree.attachedArchived')
: undefined;
toast.success(isWorktreeDelete ? 'Session archived' : 'Session deleted', {
toast.success(isWorktreeDelete
? t('sessions.sidebar.session.archive.success')
: t('sessions.sidebar.session.delete.success'), {
description: renderToastDescription(archiveNote),
action: {
label: 'OK',
label: t('sessions.sidebar.sessionDialogs.ok'),
onClick: () => { },
},
});
@@ -484,28 +499,46 @@ export const SessionDialogs: React.FC = () => {
if (deletedIds.length > 0) {
const archiveNote = !isWorktreeDelete && shouldArchive
? removeRemoteBranch
? 'Archived worktrees and removed remote branches.'
: 'Attached worktrees archived.'
? t('sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved')
: t('sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural')
: undefined;
const successDescription =
failedIds.length > 0
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be ${isWorktreeDelete ? 'archived' : 'deleted'}.`
? (isWorktreeDelete
? (failedIds.length === 1
? t('sessions.sidebar.dialogs.deleteResult.singleFailedToArchive', { count: failedIds.length })
: t('sessions.sidebar.dialogs.deleteResult.manyFailedToArchive', { count: failedIds.length }))
: (failedIds.length === 1
? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length })
: t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length })))
: deleteDialog.dateLabel
? `Removed all sessions from ${deleteDialog.dateLabel}.`
? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: deleteDialog.dateLabel })
: undefined;
const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' ');
toast.success(`${isWorktreeDelete ? 'Archived' : 'Deleted'} ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
toast.success(isWorktreeDelete
? (deletedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: deletedIds.length }))
: (deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length })), {
description: renderToastDescription(combinedDescription || undefined),
action: {
label: 'OK',
label: t('sessions.sidebar.sessionDialogs.ok'),
onClick: () => { },
},
});
}
if (failedIds.length > 0) {
toast.error(`Failed to ${isWorktreeDelete ? 'archive' : 'delete'} ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription('Please try again in a moment.'),
toast.error(isWorktreeDelete
? (failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }))
: (failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length })), {
description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')),
});
if (deletedIds.length === 0) {
setIsProcessingDelete(false);
@@ -536,16 +569,31 @@ export const SessionDialogs: React.FC = () => {
isWorktreeDelete,
canRemoveRemoteBranches,
removeSelectedWorktree,
t,
]);
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
const deleteDialogDescription = deleteDialog
? deleteDialog.mode === 'worktree'
? deleteDialog.sessions.length === 0
? 'This removes the selected worktree.'
: `This removes the selected worktree and archives ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.`
: `This action permanently removes ${deleteDialog.sessions.length === 1 ? '1 session' : `${deleteDialog.sessions.length} sessions`}${deleteDialog.dateLabel ? ` from ${deleteDialog.dateLabel}` : ''
}.`
? deleteDialog.mode === 'worktree'
? deleteDialog.sessions.length === 0
? t('sessions.sidebar.dialogs.worktreeDelete.descriptionNoLinked')
: (deleteDialog.sessions.length === 1
? t('sessions.sidebar.dialogs.worktreeDelete.descriptionOneLinked', { count: deleteDialog.sessions.length })
: t('sessions.sidebar.dialogs.worktreeDelete.descriptionManyLinked', { count: deleteDialog.sessions.length }))
: (deleteDialog.sessions.length === 1
? (deleteDialog.dateLabel
? t('sessions.sidebar.dialogs.sessionDelete.descriptionOneWithDate', {
dateLabel: deleteDialog.dateLabel,
})
: t('sessions.sidebar.dialogs.sessionDelete.descriptionOne'))
: (deleteDialog.dateLabel
? t('sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate', {
count: deleteDialog.sessions.length,
dateLabel: deleteDialog.dateLabel,
})
: t('sessions.sidebar.dialogs.sessionDelete.descriptionMany', {
count: deleteDialog.sessions.length,
})))
: '';
const deleteDialogBody = deleteDialog ? (
@@ -557,7 +605,9 @@ export const SessionDialogs: React.FC = () => {
{isWorktreeDelete && (
<div className="flex items-center gap-2">
<span className="typography-meta font-medium text-foreground">
{deleteDialog.sessions.length === 1 ? 'Linked session' : 'Linked sessions'}
{deleteDialog.sessions.length === 1
? t('sessions.sidebar.sessionDialogs.linkedSessionSingle')
: t('sessions.sidebar.sessionDialogs.linkedSessionPlural')}
</span>
<span className="typography-micro text-muted-foreground/70">
{deleteDialog.sessions.length}
@@ -578,7 +628,7 @@ export const SessionDialogs: React.FC = () => {
</span>
<span className="truncate">
{session.title || 'Untitled Session'}
{session.title || t('sessions.sidebar.session.untitled')}
</span>
</li>
))}
@@ -588,7 +638,7 @@ export const SessionDialogs: React.FC = () => {
? 'px-2.5 py-1 text-xs text-muted-foreground/70'
: 'typography-micro text-muted-foreground/70'
)}>
+{deleteDialog.sessions.length - 5} more
{t('sessions.sidebar.dialogs.sessionList.more', { count: deleteDialog.sessions.length - 5 })}
</li>
)}
</ul>
@@ -599,23 +649,25 @@ export const SessionDialogs: React.FC = () => {
<div className="space-y-2 rounded-lg bg-muted/30 p-3">
<div className="flex items-center gap-2">
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
<span className="typography-meta font-medium text-foreground">Worktree</span>
<span className="typography-meta font-medium text-foreground">{t('sessions.sidebar.sessionDialogs.worktree.label')}</span>
{targetWorktree?.label ? (
<span className="typography-micro text-muted-foreground/70">{targetWorktree.label}</span>
) : null}
</div>
<p className="typography-micro text-muted-foreground/80 break-all">
{targetWorktree ? formatPathForDisplay(targetWorktree.path, homeDirectory) : 'Worktree path unavailable.'}
{targetWorktree
? formatPathForDisplay(targetWorktree.path, homeDirectory)
: t('sessions.sidebar.sessionDialogs.worktree.pathUnavailable')}
</p>
{hasDirtyWorktrees && (
<p className="typography-micro text-status-warning">Uncommitted changes will be discarded.</p>
<p className="typography-micro text-status-warning">{t('sessions.sidebar.sessionDialogs.worktree.uncommittedWarning')}</p>
)}
</div>
) : (
<div className="rounded-xl border border-border/40 bg-sidebar/60 p-3">
<p className="typography-meta text-muted-foreground/80">
Worktree directories stay intact. Subsessions linked to the selected sessions will also be removed.
{t('sessions.sidebar.sessionDialogs.delete.note')}
</p>
</div>
)}
@@ -645,10 +697,10 @@ export const SessionDialogs: React.FC = () => {
) : (
<RiCheckboxBlankLine className="size-4" />
)}
Delete remote branch
{t('sessions.sidebar.sessionDialogs.actions.deleteRemoteBranch')}
</button>
) : (
<span className="text-xs text-muted-foreground/70">Remote branch info unavailable</span>
<span className="text-xs text-muted-foreground/70">{t('sessions.sidebar.sessionDialogs.actions.remoteBranchInfoUnavailable')}</span>
)
) : null;
@@ -674,7 +726,7 @@ export const SessionDialogs: React.FC = () => {
) : (
<RiCheckboxBlankLine className="size-4" />
)}
Delete local branch
{t('sessions.sidebar.sessionDialogs.actions.deleteLocalBranch')}
</button>
) : null;
@@ -686,10 +738,12 @@ export const SessionDialogs: React.FC = () => {
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
{t('sessions.sidebar.dialogs.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete ? 'Deleting…' : 'Delete worktree'}
{isProcessingDelete
? t('sessions.sidebar.sessionDialogs.actions.deleting')
: t('sessions.sidebar.sessionDialogs.actions.deleteWorktree')}
</Button>
</div>
</div>
@@ -702,28 +756,28 @@ export const SessionDialogs: React.FC = () => {
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="size-4 text-primary" /> : <RiCheckboxBlankLine className="size-4" />}
Never ask
{t('sessions.sidebar.dialogs.neverAsk')}
</button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
{t('sessions.sidebar.dialogs.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete
? 'Deleting'
? t('sessions.sidebar.sessionDialogs.actions.deleting')
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions'}
? t('sessions.sidebar.dialogs.deleteSession.titleAction')
: t('sessions.sidebar.dialogs.deleteSessions.titleAction')}
</Button>
</div>
</div>
);
const deleteDialogTitle = isWorktreeDelete
? 'Delete worktree'
? t('sessions.sidebar.sessionDialogs.actions.deleteWorktree')
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions';
? t('sessions.sidebar.dialogs.deleteSession.titleAction')
: t('sessions.sidebar.dialogs.deleteSessions.titleAction');
return (
<>
@@ -13,6 +13,7 @@ import {
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n';
interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder;
@@ -79,6 +80,7 @@ const SessionFolderItemBase = <TSessionNode,>({
hideActions = false,
archivedBucket = false,
}: SessionFolderItemProps<TSessionNode>) => {
const { t } = useI18n();
const [localRenaming, setLocalRenaming] = React.useState(false);
const [localDraft, setLocalDraft] = React.useState('');
const inputRef = React.useRef<HTMLInputElement | null>(null);
@@ -165,7 +167,9 @@ const SessionFolderItemBase = <TSessionNode,>({
}
}
}
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
aria-label={isCollapsed
? t('sessions.sidebar.folderItem.expandAria', { folderName: folder.name })
: t('sessions.sidebar.folderItem.collapseAria', { folderName: folder.name })}
>
<div className={cn(
'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]',
@@ -192,7 +196,7 @@ const SessionFolderItemBase = <TSessionNode,>({
onChange={(event) => handleDraftChange(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Folder name"
placeholder={t('sessions.sidebar.folderItem.namePlaceholder')}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
@@ -265,8 +269,8 @@ const SessionFolderItemBase = <TSessionNode,>({
onNewSession();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New session in ${folder.name}`}
title="New session"
aria-label={t('sessions.sidebar.folderItem.newSessionAria', { folderName: folder.name })}
title={t('sessions.sidebar.project.actions.newSession')}
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
@@ -280,8 +284,8 @@ const SessionFolderItemBase = <TSessionNode,>({
onNewSubFolder();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New sub-folder in ${folder.name}`}
title="New sub-folder"
aria-label={t('sessions.sidebar.folderItem.newSubfolderAria', { folderName: folder.name })}
title={t('sessions.sidebar.folderItem.newSubfolder')}
>
<RiFolderAddLine className="h-3.5 w-3.5" />
</button>
@@ -294,7 +298,7 @@ const SessionFolderItemBase = <TSessionNode,>({
handleStartRename();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Rename folder ${folder.name}`}
aria-label={t('sessions.sidebar.folderItem.renameAria', { folderName: folder.name })}
>
<RiPencilAiLine className="h-3.5 w-3.5" />
</button>
@@ -306,7 +310,9 @@ const SessionFolderItemBase = <TSessionNode,>({
onDelete();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={archivedBucket ? `Delete archived sessions in folder ${folder.name}` : `Delete folder ${folder.name}`}
aria-label={archivedBucket
? t('sessions.sidebar.folderItem.deleteArchivedInFolderAria', { folderName: folder.name })
: t('sessions.sidebar.folderItem.deleteFolderAria', { folderName: folder.name })}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
@@ -327,7 +333,7 @@ const SessionFolderItemBase = <TSessionNode,>({
)
) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
Empty folder
{t('sessions.sidebar.folderItem.emptyFolder')}
</div>
) : null}
</div>
@@ -3,6 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { RiLayoutLeftLine } from '@remixicon/react';
import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { isDesktopWindowFullscreen as getDesktopWindowFullscreen, onDesktopWindowResized, startDesktopWindowDrag } from '@/lib/desktopNative';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -125,6 +126,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
hideDirectoryControls = false,
showOnlyMainWorkspace = false,
}) => {
const { t } = useI18n();
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
@@ -542,8 +544,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const emptyState = (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">No sessions yet</p>
<p className="typography-meta mt-1">Create your first session to start coding.</p>
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noSessions.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noSessions.description')}</p>
</div>
);
@@ -574,16 +576,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
void updateStore.checkForUpdates().then(() => {
const { available, error } = useUpdateStore.getState();
if (error) {
toast.error('Failed to check for updates', { description: error });
toast.error(t('sessions.sidebar.updateCheck.errorTitle'), { description: error });
return;
}
if (!available) {
toast.success('You are on the latest version');
toast.success(t('sessions.sidebar.updateCheck.latestVersion'));
return;
}
setUpdateDialogOpen(true);
});
}, [updateStore]);
}, [t, updateStore]);
const handleOpenSettings = React.useCallback(() => {
if (mobileVariant) {
@@ -664,21 +666,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
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('sessions.sidebar.directory.errorAddProjectTitle'), {
description: t('sessions.sidebar.directory.errorAddProjectDescription'),
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'), {
description: result.error,
});
}
})
.catch((error) => {
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'));
});
}, [addProject, tauriIpcAvailable]);
}, [addProject, t, tauriIpcAvailable]);
// Auto-expand parent session when navigating to a subagent (child) session
React.useEffect(() => {
@@ -722,12 +724,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
toggleFolderCollapse(parentId);
}
const newFolder = createFolder(scopeKey, 'New folder', parentId);
const newFolder = createFolder(scopeKey, t('sessions.sidebar.folder.newFolderName'), parentId);
setRenamingFolderId(newFolder.id);
setRenameFolderDraft(newFolder.name);
return newFolder;
},
[collapsedFolderIds, toggleFolderCollapse, createFolder],
[collapsedFolderIds, toggleFolderCollapse, createFolder, t],
);
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
@@ -890,8 +892,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const searchEmptyState = (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">No matching sessions</p>
<p className="typography-meta mt-1">Try a different title, branch, folder, or path.</p>
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noMatches.description')}</p>
</div>
);
@@ -1037,9 +1039,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
};
return [
{ key: 'active-now' as const, title: 'recent', items: activeNowSessions.map(toItem) },
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: activeNowSessions.map(toItem) },
];
}, [activeNowSessions, sessionSidebarMetaById]);
}, [activeNowSessions, sessionSidebarMetaById, t]);
const recentSessionIds = React.useMemo(() => {
return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id)));
@@ -1468,15 +1470,31 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`);
if (failedIds.length > 0) toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) toast.success(`Archived ${archivedIds.length} session${archivedIds.length === 1 ? '' : 's'}`);
if (failedIds.length > 0) toast.error(`Failed to archive ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds]);
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
const count = selectedIds.size;
@@ -1576,13 +1594,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
type="button"
onClick={toggleSidebar}
className={desktopSidebarToggleButtonClass}
aria-label="Close sessions"
aria-label={t('sessions.sidebar.header.actions.closeSessions')}
>
<RiLayoutLeftLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close sessions</p>
<p>{t('sessions.sidebar.header.actions.closeSessions')}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -19,6 +19,7 @@ import { useAgentsStore } from '@/stores/useAgentsStore';
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
import { cn } from '@/lib/utils';
import { RiArrowDownSLine } from '@remixicon/react';
import { useI18n } from '@/lib/i18n';
type TodoSendTarget = 'session' | 'worktree';
@@ -58,7 +59,8 @@ type ThinkingPillProps = {
};
const ThinkingPill = ({ value, options, disabled, onChange }: ThinkingPillProps) => {
const label = value || 'Default';
const { t } = useI18n();
const label = value || t('rightSidebar.contextNotesTodo.sendDialog.variant.default');
const trigger = (
<div
@@ -79,7 +81,9 @@ const ThinkingPill = ({ value, options, disabled, onChange }: ThinkingPillProps)
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-w-[220px]">
<DropdownMenuItem className="typography-meta" onSelect={() => onChange('')}>
<span className={cn('font-medium', !value && 'text-primary')}>Default</span>
<span className={cn('font-medium', !value && 'text-primary')}>
{t('rightSidebar.contextNotesTodo.sendDialog.variant.default')}
</span>
</DropdownMenuItem>
{options.map((option) => (
<DropdownMenuItem
@@ -98,6 +102,7 @@ const ThinkingPill = ({ value, options, disabled, onChange }: ThinkingPillProps)
};
export function TodoSendDialog(props: TodoSendDialogProps) {
const { t } = useI18n();
const { open, onOpenChange, target, projectDirectory, submitting = false, onConfirm } = props;
const loadProviders = useConfigStore((state) => state.loadProviders);
@@ -185,7 +190,9 @@ export function TodoSendDialog(props: TodoSendDialogProps) {
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, handleSubmit]);
const title = target === 'worktree' ? 'Send to new worktree' : 'Send to new session';
const title = target === 'worktree'
? t('rightSidebar.contextNotesTodo.sendDialog.title.newWorktree')
: t('rightSidebar.contextNotesTodo.sendDialog.title.newSession');
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!submitting) onOpenChange(nextOpen); }}>
@@ -217,10 +224,12 @@ export function TodoSendDialog(props: TodoSendDialogProps) {
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
{t('rightSidebar.contextNotesTodo.sendDialog.actions.cancel')}
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canConfirm || submitting}>
{submitting ? 'Sending' : 'Send'}
{submitting
? t('rightSidebar.contextNotesTodo.sendDialog.actions.sending')
: t('rightSidebar.contextNotesTodo.sendDialog.actions.send')}
</Button>
</div>
</DialogContent>
@@ -15,6 +15,7 @@ import {
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n';
type Props = {
selectedCount: number;
@@ -41,15 +42,18 @@ export const BulkActionBar: React.FC<Props> = ({
onDelete,
onDone,
}) => {
const { t } = useI18n();
const canMoveToFolder = Boolean(scopeKey) && !archivedBucket;
const destructiveLabel = archivedBucket ? 'Delete' : 'Archive';
const destructiveLabel = archivedBucket
? t('sessions.sidebar.bulkActions.delete')
: t('sessions.sidebar.bulkActions.archive');
const iconButtonClass = 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
const destructiveIconButtonClass = 'inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50';
return (
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2.5 py-1.5">
<span className="typography-ui-label text-muted-foreground whitespace-nowrap">
{selectedCount} selected
{t('sessions.sidebar.bulkActions.selectedCount', { count: selectedCount })}
</span>
<div className="ml-auto flex items-center gap-0.5">
@@ -61,18 +65,18 @@ export const BulkActionBar: React.FC<Props> = ({
<button
type="button"
className={iconButtonClass}
aria-label="Move to folder"
aria-label={t('sessions.sidebar.bulkActions.moveToFolder')}
>
<RiFolderLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Move to folder</p></TooltipContent>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.moveToFolder')}</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">
No folders yet
{t('sessions.sidebar.folders.none')}
</DropdownMenuItem>
) : (
scopeFolders.map((folder) => (
@@ -84,7 +88,7 @@ export const BulkActionBar: React.FC<Props> = ({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCreateFolderAndMove}>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
{t('sessions.sidebar.folders.newFolderEllipsis')}
</DropdownMenuItem>
{canRemoveFromFolder ? (
<DropdownMenuItem
@@ -92,7 +96,7 @@ export const BulkActionBar: React.FC<Props> = ({
className="text-destructive focus:text-destructive"
>
<RiCloseLine className="mr-1 h-4 w-4" />
Remove from folder
{t('sessions.sidebar.folders.removeFromFolder')}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -119,12 +123,12 @@ export const BulkActionBar: React.FC<Props> = ({
type="button"
onClick={onDone}
className={iconButtonClass}
aria-label="Exit selection"
aria-label={t('sessions.sidebar.header.actions.exitSelection')}
>
<RiCloseLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Exit selection</p></TooltipContent>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.header.actions.exitSelection')}</p></TooltipContent>
</Tooltip>
</div>
</div>
@@ -2,6 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
export type DeleteSessionConfirmState = {
session: Session;
@@ -16,21 +17,45 @@ export function SessionDeleteConfirmDialog(props: {
setShowDeletionDialog: (next: boolean) => void;
onConfirm: () => Promise<void> | void;
}): React.ReactNode {
const { t } = useI18n();
const { value, setValue, showDeletionDialog, setShowDeletionDialog, onConfirm } = props;
const untitledSession = t('sessions.sidebar.session.untitled');
return (
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) setValue(null); }}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
<DialogHeader>
<DialogTitle>{value?.archivedBucket ? 'Delete session?' : 'Archive session?'}</DialogTitle>
<DialogTitle>{value?.archivedBucket
? t('sessions.sidebar.dialogs.deleteSession.title')
: t('sessions.sidebar.dialogs.archiveSession.title')}</DialogTitle>
<DialogDescription>
{value && value.descendantCount > 0
? value.archivedBucket
? `"${value.session.title || 'Untitled Session'}" and its ${value.descendantCount} sub-task${value.descendantCount === 1 ? '' : 's'} will be permanently deleted.`
: `"${value.session.title || 'Untitled Session'}" and its ${value.descendantCount} sub-task${value.descendantCount === 1 ? '' : 's'} will be archived.`
? value.descendantCount === 1
? t('sessions.sidebar.dialogs.deleteSession.withOneSubtask', {
sessionTitle: value.session.title || untitledSession,
count: value.descendantCount,
})
: t('sessions.sidebar.dialogs.deleteSession.withManySubtasks', {
sessionTitle: value.session.title || untitledSession,
count: value.descendantCount,
})
: value.descendantCount === 1
? t('sessions.sidebar.dialogs.archiveSession.withOneSubtask', {
sessionTitle: value.session.title || untitledSession,
count: value.descendantCount,
})
: t('sessions.sidebar.dialogs.archiveSession.withManySubtasks', {
sessionTitle: value.session.title || untitledSession,
count: value.descendantCount,
})
: value?.archivedBucket
? `"${value?.session.title || 'Untitled Session'}" will be permanently deleted.`
: `"${value?.session.title || 'Untitled Session'}" will be archived.`}
? t('sessions.sidebar.dialogs.deleteSession.single', {
sessionTitle: value?.session.title || untitledSession,
})
: t('sessions.sidebar.dialogs.archiveSession.single', {
sessionTitle: value?.session.title || untitledSession,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter className="w-full sm:items-center sm:justify-between">
@@ -41,7 +66,7 @@ export function SessionDeleteConfirmDialog(props: {
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="h-4 w-4 text-primary" /> : <RiCheckboxBlankLine className="h-4 w-4" />}
Never ask
{t('sessions.sidebar.dialogs.neverAsk')}
</button>
<div className="flex items-center gap-2">
<button
@@ -49,14 +74,14 @@ export function SessionDeleteConfirmDialog(props: {
onClick={() => setValue(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
{t('sessions.sidebar.dialogs.cancel')}
</button>
<button
type="button"
onClick={() => void onConfirm()}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
{value?.archivedBucket ? 'Delete' : 'Archive'}
{value?.archivedBucket ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')}
</button>
</div>
</DialogFooter>
@@ -77,14 +102,24 @@ export function BulkSessionDeleteConfirmDialog(props: {
setShowDeletionDialog: (next: boolean) => void;
onConfirm: () => Promise<void> | void;
}): React.ReactNode {
const { t } = useI18n();
const { value, setValue, showDeletionDialog, setShowDeletionDialog, onConfirm } = props;
const archived = value?.archivedBucket === true;
const n = value?.sessionCount ?? 0;
const plural = n === 1 ? '' : 's';
const title = archived ? 'Delete sessions?' : 'Archive sessions?';
const title = archived
? (n === 1
? t('sessions.sidebar.dialogs.deleteSession.title')
: t('sessions.sidebar.dialogs.deleteSessions.title'))
: (n === 1
? t('sessions.sidebar.dialogs.archiveSession.title')
: t('sessions.sidebar.dialogs.archiveSessions.title'));
const description = archived
? `${n} session${plural} will be permanently deleted.`
: `${n} session${plural} will be archived.`;
? (n === 1
? t('sessions.sidebar.dialogs.deleteSessions.singleDescription', { count: n })
: t('sessions.sidebar.dialogs.deleteSessions.pluralDescription', { count: n }))
: (n === 1
? t('sessions.sidebar.dialogs.archiveSessions.singleDescription', { count: n })
: t('sessions.sidebar.dialogs.archiveSessions.pluralDescription', { count: n }));
return (
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) setValue(null); }}>
@@ -101,7 +136,7 @@ export function BulkSessionDeleteConfirmDialog(props: {
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="h-4 w-4 text-primary" /> : <RiCheckboxBlankLine className="h-4 w-4" />}
Never ask
{t('sessions.sidebar.dialogs.neverAsk')}
</button>
<div className="flex items-center gap-2">
<button
@@ -109,14 +144,14 @@ export function BulkSessionDeleteConfirmDialog(props: {
onClick={() => setValue(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
{t('sessions.sidebar.dialogs.cancel')}
</button>
<button
type="button"
onClick={() => void onConfirm()}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
{archived ? 'Delete' : 'Archive'}
{archived ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')}
</button>
</div>
</DialogFooter>
@@ -138,17 +173,32 @@ export function FolderDeleteConfirmDialog(props: {
setValue: (next: DeleteFolderConfirmState) => void;
onConfirm: () => void;
}): React.ReactNode {
const { t } = useI18n();
const { value, setValue, onConfirm } = props;
return (
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) setValue(null); }}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
<DialogHeader>
<DialogTitle>Delete folder?</DialogTitle>
<DialogTitle>{t('sessions.sidebar.dialogs.deleteFolder.title')}</DialogTitle>
<DialogDescription>
{value && (value.subFolderCount > 0 || value.sessionCount > 0)
? `"${value.folderName}" will be deleted${value.subFolderCount > 0 ? ` along with ${value.subFolderCount} sub-folder${value.subFolderCount === 1 ? '' : 's'}` : ''}. Sessions inside will not be deleted.`
: `"${value?.folderName}" will be permanently deleted.`}
? value.subFolderCount > 0
? value.subFolderCount === 1
? t('sessions.sidebar.dialogs.deleteFolder.withOneSubfolder', {
folderName: value.folderName,
count: value.subFolderCount,
})
: t('sessions.sidebar.dialogs.deleteFolder.withManySubfolders', {
folderName: value.folderName,
count: value.subFolderCount,
})
: t('sessions.sidebar.dialogs.deleteFolder.withContentsNoSubfolders', {
folderName: value.folderName,
})
: t('sessions.sidebar.dialogs.deleteFolder.single', {
folderName: value?.folderName ?? '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -157,14 +207,14 @@ export function FolderDeleteConfirmDialog(props: {
onClick={() => setValue(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
{t('sessions.sidebar.dialogs.cancel')}
</button>
<button
type="button"
onClick={onConfirm}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
Delete
{t('sessions.sidebar.bulkActions.delete')}
</button>
</DialogFooter>
</DialogContent>
@@ -21,6 +21,7 @@ import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePa
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
type DeleteFolderConfirm = {
scopeKey: string;
@@ -96,6 +97,7 @@ type Props = {
};
export function SessionGroupSection(props: Props): React.ReactNode {
const { t } = useI18n();
const {
group,
groupKey,
@@ -277,21 +279,28 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
const prVisualState = prIndicator?.visualState ?? null;
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? `${prIndicator.checks.success}/${prIndicator.checks.total} checks passed`
? t('sessions.sidebar.group.pr.checksPassed', {
success: prIndicator.checks.success,
total: prIndicator.checks.total,
})
: null;
const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? [
prIndicator.checks.failure > 0 ? `${prIndicator.checks.failure} failing` : null,
prIndicator.checks.pending > 0 ? `${prIndicator.checks.pending} pending` : null,
prIndicator.checks.failure > 0
? t('sessions.sidebar.group.pr.failingCount', { count: prIndicator.checks.failure })
: null,
prIndicator.checks.pending > 0
? t('sessions.sidebar.group.pr.pendingCount', { count: prIndicator.checks.pending })
: null,
].filter((item): item is string => Boolean(item)).join(', ')
: null;
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty'
? 'Conflicts or blocked'
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? 'Mergeable' : null))
? t('sessions.sidebar.group.pr.conflictsOrBlocked')
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? t('sessions.sidebar.group.pr.mergeable') : null))
: null;
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
? `Merge state: ${prIndicator.mergeableState}`
? t('sessions.sidebar.group.pr.mergeState', { state: prIndicator.mergeableState })
: null;
const baseBranchLabel = prIndicator?.base ?? null;
const headBranchLabel = prIndicator?.head ?? null;
@@ -303,20 +312,22 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}
switch (prIndicator.visualState) {
case 'merged':
return { label: 'Merged', color: 'var(--pr-merged)' };
return { label: t('sessions.sidebar.group.pr.status.merged'), color: 'var(--pr-merged)' };
case 'open':
return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
? { label: 'Ready to merge', color: 'var(--pr-open)' }
: { label: 'PR open', color: 'var(--pr-open)' };
? { label: t('sessions.sidebar.group.pr.status.readyToMerge'), color: 'var(--pr-open)' }
: { label: t('sessions.sidebar.group.pr.status.open'), color: 'var(--pr-open)' };
case 'blocked':
return {
label: prIndicator.mergeableState === 'dirty' ? 'Merge conflicts' : 'Merge blocked',
label: prIndicator.mergeableState === 'dirty'
? t('sessions.sidebar.group.pr.status.mergeConflicts')
: t('sessions.sidebar.group.pr.status.mergeBlocked'),
color: 'var(--pr-blocked)',
};
case 'draft':
return { label: 'Draft PR', color: 'var(--pr-draft)' };
return { label: t('sessions.sidebar.group.pr.status.draft'), color: 'var(--pr-draft)' };
case 'closed':
return { label: 'Closed', color: 'var(--pr-closed)' };
return { label: t('sessions.sidebar.group.pr.status.closed'), color: 'var(--pr-closed)' };
default:
return null;
}
@@ -448,7 +459,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
{group.isArchivedBucket ? 'No archived sessions yet.' : 'No sessions in this workspace yet.'}
{group.isArchivedBucket
? t('sessions.sidebar.group.empty.noArchivedSessions')
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div>
) : null}
{remainingCount > 0 && !isExpanded ? (
@@ -457,7 +470,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
onClick={() => toggleGroupSessionLimit(groupKey)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
{remainingCount === 1
? t('sessions.sidebar.group.showMoreSingle', { count: remainingCount })
: t('sessions.sidebar.group.showMorePlural', { count: remainingCount })}
</button>
) : null}
{isExpanded && totalSessions > maxVisible ? (
@@ -466,7 +481,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
onClick={() => toggleGroupSessionLimit(groupKey)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
{t('sessions.sidebar.group.showFewer')}
</button>
) : null}
</SessionFolderDndScope>
@@ -491,7 +506,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
onToggleCollapsedGroup(groupKey);
}
}}
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
aria-label={isCollapsed
? t('sessions.sidebar.group.expandAria', { label: group.label })
: t('sessions.sidebar.group.collapseAria', { label: group.label })}
aria-expanded={!isCollapsed}
>
<div
@@ -661,12 +678,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
});
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Delete archived sessions in ${group.label}`}
aria-label={t('sessions.sidebar.group.actions.deleteArchivedInGroupAria', { label: group.label })}
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Delete archived sessions</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.group.actions.deleteArchivedSessions')}</p></TooltipContent>
</Tooltip>
</div>
) : null}
@@ -685,12 +702,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
});
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Delete ${group.label}`}
aria-label={t('sessions.sidebar.group.actions.deleteGroupAria', { label: group.label })}
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Delete worktree</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.group.actions.deleteWorktree')}</p></TooltipContent>
</Tooltip>
</div>
) : null}
@@ -708,12 +725,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
openNewSessionDraft({ directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New draft session in ${group.label}`}
aria-label={t('sessions.sidebar.group.actions.newDraftInGroupAria', { label: group.label })}
>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New draft session</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.project.actions.newDraftSession')}</p></TooltipContent>
</Tooltip>
</div>
) : null}
@@ -35,7 +35,7 @@ import {
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { toast } from '@/components/ui';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabel, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore } from '@/sync/viewport-store';
@@ -45,6 +45,7 @@ import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, r
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { useI18n } from '@/lib/i18n';
type Folder = { id: string; name: string; sessionIds: string[] };
@@ -197,6 +198,7 @@ const areEqual = (prev: Props, next: Props): boolean => {
};
function SessionNodeItemComponent(props: Props): React.ReactNode {
const { t } = useI18n();
const {
node,
depth = 0,
@@ -297,7 +299,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
const isMissingDirectory = directoryState === 'missing';
const isActive = currentSessionId === session.id;
const sessionTitle = resolvedSession.title || 'Untitled Session';
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
const hasChildren = node.children.length > 0;
const isPinnedSession = pinnedSessionIds.has(session.id);
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
@@ -312,7 +314,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const handleExportSession = React.useCallback(async () => {
if (!sessionDirectory) {
toast.error('Nothing to export');
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
@@ -320,7 +322,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) {
toast.error('Nothing to export');
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
@@ -329,13 +331,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
if (savedPath) {
toast.success('Session exported', {
toast.success(t('sessions.sidebar.session.export.success'), {
action: {
label: getExportRevealLabel(),
label: t(getExportRevealLabelKey()),
onClick: () => {
void revealExportedMarkdown(savedPath).then((revealed) => {
if (!revealed) {
toast.error('Failed to reveal path');
toast.error(t('sessions.sidebar.session.export.failedRevealPath'));
}
});
},
@@ -345,8 +347,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
downloadAsMarkdown(markdown, filename);
toast.success('Session exported');
}, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync]);
toast.success(t('sessions.sidebar.session.export.success'));
}, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync, t]);
if (editingId === session.id) {
return (
@@ -368,7 +370,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
onChange={(event) => setEditTitle(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Rename session"
placeholder={t('sessions.sidebar.session.menu.rename')}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
@@ -408,15 +410,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
? (
<span
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
aria-label="Session active"
title="Session active"
aria-label={t('sessions.sidebar.session.status.active')}
title={t('sessions.sidebar.session.status.active')}
/>
)
: (
<span
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
aria-label="Unread updates"
title="Unread updates"
aria-label={t('sessions.sidebar.session.status.unread')}
title={t('sessions.sidebar.session.status.unread')}
/>
);
const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
@@ -455,7 +457,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
: '',
)}
aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}
aria-label={isExpanded
? t('sessions.sidebar.session.subsessions.collapse')
: t('sessions.sidebar.session.subsessions.expand')}
>
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
</span>
@@ -538,31 +542,33 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className="[&>svg]:mr-1"
>
<RiPencilAiLine className="mr-1 h-4 w-4" />
Rename
{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
{isPinnedSession ? 'Unpin session' : 'Pin session'}
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
</DropdownMenuItem>
{!resolvedSession.share ? (
<DropdownMenuItem onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
<RiShare2Line className="mr-1 h-4 w-4" />
Share
{t('sessions.sidebar.session.menu.share')}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
{copiedSessionId === session.id
? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />{t('sessions.sidebar.session.menu.copied')}</>
: <><RiFileCopyLine className="mr-1 h-4 w-4" />{t('sessions.sidebar.session.menu.copyLink')}</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
Unshare
{t('sessions.sidebar.session.menu.unshare')}
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
<RiDownloadLine className="mr-1 h-4 w-4" />
Export Markdown
{t('sessions.sidebar.session.menu.exportMarkdown')}
</DropdownMenuItem>
{sessionDirectory && !archivedBucket ? (() => {
@@ -572,10 +578,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
<DropdownMenuItem disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</DropdownMenuItem>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
@@ -587,12 +593,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
{t('sessions.sidebar.folders.newFolderEllipsis')}
</DropdownMenuItem>
{currentFolderId ? (
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<RiCloseLine className="mr-1 h-4 w-4" />
Remove from folder
{t('sessions.sidebar.folders.removeFromFolder')}
</DropdownMenuItem>
) : null}
</DropdownMenuSubContent>
@@ -615,15 +621,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className="[&>svg]:mr-1"
>
<RiChat4Line className="mr-1 h-4 w-4" />
<span className="truncate">Open in Side Panel</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
<span className="truncate">{t('sessions.sidebar.session.menu.openInSidePanel')}</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">{t('sessions.sidebar.session.menu.betaBadge')}</span>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<RiDeleteBinLine className="mr-1 h-4 w-4" />
{archivedBucket ? 'Delete' : 'Archive'}
{archivedBucket ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')}
</DropdownMenuItem>
</DropdownMenuContent>
);
@@ -669,7 +675,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
)}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label={t('sessions.sidebar.session.status.pinned')} /> : null}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{mobileVariant ? <span className="ml-2 flex-shrink-0 text-[0.72rem] text-muted-foreground/75">{sessionCompactUpdatedLabel}</span> : null}
{!mobileVariant ? (
@@ -685,7 +691,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</div>
) : null}
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
<RiShieldLine className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span>
</span>
@@ -735,10 +741,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{inlineStatusMarker}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label={t('sessions.sidebar.session.status.pinned')} /> : null}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{pendingPermissionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
<RiShieldLine className="h-3 w-3" />
<span className="leading-none">{pendingPermissionCount}</span>
</span>
@@ -785,7 +791,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
)}
aria-label="Session menu"
aria-label={t('sessions.sidebar.session.menu.label')}
onPointerDown={handleMenuTriggerPointerDown}
onMouseDown={handleMenuTriggerMouseDown}
onClick={handleMenuTriggerClick}
@@ -2,6 +2,7 @@ import React from 'react';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { SessionNode } from './types';
import { useI18n } from '@/lib/i18n';
type ActivityItem = {
node: SessionNode;
@@ -27,6 +28,7 @@ type Props = {
const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections({ sections, renderSessionNode }: Props): React.ReactNode {
const { t } = useI18n();
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set());
@@ -86,18 +88,20 @@ export function SidebarActivitySections({ sections, renderSessionNode }: Props):
<button
type="button"
onClick={() => toggleSectionLimit(section.key)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{remainingCount === 1
? t('sessions.sidebar.group.showMoreSingle', { count: remainingCount })
: t('sessions.sidebar.group.showMorePlural', { count: remainingCount })}
</button>
) : null}
{isExpanded && section.items.length > MAX_VISIBLE_RECENT_SESSIONS ? (
<button
type="button"
onClick={() => toggleSectionLimit(section.key)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showFewer')}
</button>
) : null}
</div>
@@ -2,6 +2,7 @@ import React from 'react';
import { RiInformationLine, RiQuestionLine, RiSettings3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
type Props = {
onOpenSettings: () => void;
@@ -22,33 +23,35 @@ export function SidebarFooter({
showRuntimeButtons = true,
showUpdateButton = true,
}: Props): React.ReactNode {
const { t } = useI18n();
return (
<div className="flex shrink-0 items-center justify-start gap-1 px-2.5 py-2">
{showRuntimeButtons ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label="Settings">
<button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.settings')}>
<RiSettings3Line className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Settings</p></TooltipContent>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.settings')}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label="Shortcuts">
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.shortcuts')}>
<RiQuestionLine className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>Shortcuts</p></TooltipContent>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.shortcuts')}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label="About OpenChamber">
<button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.aboutOpenChamber')}>
<RiInformationLine className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>About OpenChamber</p></TooltipContent>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.aboutOpenChamber')}</p></TooltipContent>
</Tooltip>
</>
) : null}
@@ -60,7 +63,7 @@ export function SidebarFooter({
className="ml-auto border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)] hover:bg-[var(--status-info-background)]/80 hover:text-[var(--status-info)] dark:border-[var(--status-info-border)] dark:bg-[var(--status-info-background)] dark:hover:bg-[var(--status-info-background)]/80"
onClick={onOpenUpdate}
>
Update
{t('sessions.sidebar.footer.actions.update')}
</Button>
) : null}
</div>
@@ -23,6 +23,7 @@ import {
import { cn } from '@/lib/utils';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useI18n } from '@/lib/i18n';
type Props = {
hideDirectoryControls: boolean;
@@ -50,6 +51,7 @@ type Props = {
};
export function SidebarHeader(props: Props): React.ReactNode {
const { t } = useI18n();
const {
hideDirectoryControls,
handleOpenDirectoryDialog,
@@ -105,12 +107,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={onToggleSidebar}
className="inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50"
aria-label="Close sessions"
aria-label={t('sessions.sidebar.header.actions.closeSessions')}
>
<RiLayoutLeftLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Close sessions</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.closeSessions')}</p></TooltipContent>
</Tooltip>
) : null}
<Tooltip>
@@ -119,12 +121,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={handleOpenDirectoryDialog}
className={headerActionButtonClass}
aria-label="Add project"
aria-label={t('sessions.sidebar.header.actions.addProject')}
>
<RiFolderAddLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Add project</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -132,12 +134,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={handleNewSession}
className={headerActionButtonClass}
aria-label="New session"
aria-label={t('sessions.sidebar.header.actions.newSession')}
>
<RiChatNewLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newSession')}</p></TooltipContent>
</Tooltip>
<Tooltip>
@@ -146,13 +148,13 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={openMultiRunLauncher}
className={headerActionButtonClass}
aria-label="New multi-run"
aria-label={t('sessions.sidebar.header.actions.newMultiRun')}
disabled={!canOpenMultiRun}
>
<ArrowsMerge className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent>
</Tooltip>
</div>
@@ -163,12 +165,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={openScheduledTasksDialog}
className={headerActionButtonClass}
aria-label="Scheduled tasks"
aria-label={t('sessions.sidebar.header.actions.scheduledTasks')}
>
<RiCalendarScheduleLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Scheduled tasks</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
</Tooltip>
<Tooltip>
@@ -177,13 +179,13 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
className={headerActionButtonClass}
aria-label="Search sessions"
aria-label={t('sessions.sidebar.header.actions.searchSessions')}
aria-expanded={isSessionSearchOpen}
>
<RiSearchLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent>
</Tooltip>
<Tooltip>
@@ -192,14 +194,18 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={onToggleSelectionMode}
className={cn(headerActionButtonClass, selectionModeEnabled && 'bg-interactive-hover text-primary')}
aria-label={selectionModeEnabled ? 'Exit selection' : 'Select sessions'}
aria-label={selectionModeEnabled
? t('sessions.sidebar.header.actions.exitSelection')
: t('sessions.sidebar.header.actions.selectSessions')}
aria-pressed={selectionModeEnabled}
>
<RiCheckboxMultipleLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{selectionModeEnabled ? 'Exit selection' : 'Select sessions'}</p>
<p>{selectionModeEnabled
? t('sessions.sidebar.header.actions.exitSelection')
: t('sessions.sidebar.header.actions.selectSessions')}</p>
</TooltipContent>
</Tooltip>
@@ -210,37 +216,37 @@ export function SidebarHeader(props: Props): React.ReactNode {
<button
type="button"
className={headerActionButtonClass}
aria-label="Session display mode"
aria-label={t('sessions.sidebar.header.actions.sessionDisplayMode')}
>
<RiEqualizer2Line className={headerActionIconClass} />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.displayMode.label')}</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem
onClick={() => setDisplayMode('default')}
className="flex items-center justify-between"
>
<span>Default</span>
<span>{t('sessions.sidebar.header.displayMode.default')}</span>
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDisplayMode('minimal')}
className="flex items-center justify-between"
>
<span>Minimal</span>
<span>{t('sessions.sidebar.header.displayMode.minimal')}</span>
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
<RiContractUpDownLine className="h-4 w-4" />
<span>Collapse all</span>
<span>{t('sessions.sidebar.header.displayMode.collapseAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
<RiExpandUpDownLine className="h-4 w-4" />
<span>Expand all</span>
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -251,9 +257,11 @@ export function SidebarHeader(props: Props): React.ReactNode {
<div className="pb-1">
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
{hasSessionSearchQuery ? (
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
<span>{searchMatchCount === 1
? t('sessions.sidebar.header.search.matchCountSingle', { count: searchMatchCount })
: t('sessions.sidebar.header.search.matchCountPlural', { count: searchMatchCount })}</span>
) : <span />}
<span>Esc to clear</span>
<span>{t('sessions.sidebar.header.search.escapeHint')}</span>
</div>
<div className="relative">
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -261,7 +269,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
ref={sessionSearchInputRef}
value={sessionSearchQuery}
onChange={(event) => setSessionSearchQuery(event.target.value)}
placeholder="Search sessions..."
placeholder={t('sessions.sidebar.header.search.placeholder')}
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onKeyDown={(event) => {
if (event.key === 'Escape') {
@@ -279,7 +287,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
type="button"
onClick={() => setSessionSearchQuery('')}
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Clear search"
aria-label={t('sessions.sidebar.header.search.clear')}
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
@@ -15,6 +15,7 @@ import type { SessionGroup } from './types';
import type { SortableDragHandleProps } from './sortableItems';
import { SortableGroupItem, SortableProjectItem } from './sortableItems';
import { formatProjectLabel } from './utils';
import { useI18n } from '@/lib/i18n';
type ProjectSection = {
project: {
@@ -64,6 +65,7 @@ type Props = {
};
export function SidebarProjectsList(props: Props): React.ReactNode {
const { t } = useI18n();
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -96,7 +98,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
?? activeSection.groups.find((candidate) => candidate.isMain)
?? activeSection.groups[0];
if (!primaryGroup) {
return <div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>;
return <div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>;
}
const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket);
const groupsToRender = [
@@ -222,7 +224,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
<DragOverlay dropAnimation={null} />
</DndContext>
) : (
<div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>
<div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>
)}
</div>
) : null}
@@ -2,6 +2,7 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
session: Session;
@@ -43,6 +44,7 @@ type Args = {
};
export const useSessionActions = (args: Args) => {
const { t } = useI18n();
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
const copyTimeout = React.useRef<number | null>(null);
@@ -120,19 +122,19 @@ export const useSessionActions = (args: Args) => {
const handleShareSession = React.useCallback(async (session: Session) => {
const result = await args.shareSession(session.id);
if (result && result.share?.url) {
toast.success('Session shared', {
description: 'You can copy the link from the menu.',
toast.success(t('sessions.sidebar.session.share.successTitle'), {
description: t('sessions.sidebar.session.share.successDescription'),
});
} else {
toast.error('Unable to share session');
toast.error(t('sessions.sidebar.session.share.error'));
}
}, [args]);
}, [args, t]);
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
void copyTextToClipboard(url)
.then((result) => {
if (!result.ok) {
toast.error('Failed to copy URL');
toast.error(t('sessions.sidebar.session.share.copyUrlError'));
return;
}
setCopiedSessionId(sessionId);
@@ -145,18 +147,18 @@ export const useSessionActions = (args: Args) => {
}, 2000);
})
.catch(() => {
toast.error('Failed to copy URL');
toast.error(t('sessions.sidebar.session.share.copyUrlError'));
});
}, []);
}, [t]);
const handleUnshareSession = React.useCallback(async (sessionId: string) => {
const result = await args.unshareSession(sessionId);
if (result) {
toast.success('Session unshared');
toast.success(t('sessions.sidebar.session.unshare.success'));
} else {
toast.error('Unable to unshare session');
toast.error(t('sessions.sidebar.session.unshare.error'));
}
}, [args]);
}, [args, t]);
const collectDescendants = React.useCallback((sessionId: string): Session[] => {
const collected: Session[] = [];
@@ -180,9 +182,13 @@ export const useSessionActions = (args: Args) => {
? await args.deleteSession(session.id)
: await args.archiveSession(session.id);
if (success) {
toast.success(shouldHardDelete ? 'Session deleted' : 'Session archived');
toast.success(shouldHardDelete
? t('sessions.sidebar.session.delete.success')
: t('sessions.sidebar.session.archive.success'));
} else {
toast.error(shouldHardDelete ? 'Failed to delete session' : 'Failed to archive session');
toast.error(shouldHardDelete
? t('sessions.sidebar.session.delete.error')
: t('sessions.sidebar.session.archive.error'));
}
return;
}
@@ -191,23 +197,31 @@ export const useSessionActions = (args: Args) => {
if (shouldHardDelete) {
const { deletedIds, failedIds } = await args.deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`);
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
return;
}
const { archivedIds, failedIds } = await args.archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(`Archived ${archivedIds.length} session${archivedIds.length === 1 ? '' : 's'}`);
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(`Failed to archive ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
},
[args, collectDescendants],
[args, collectDescendants, t],
);
const handleDeleteSession = React.useCallback(
@@ -10,6 +10,7 @@ import {
normalizePath,
} from '../utils';
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
type Args = {
homeDirectory: string | null;
@@ -22,15 +23,16 @@ type Args = {
const isArchivedSession = (session: Session): boolean => Boolean(session.time?.archived);
export const useSessionGrouping = (args: Args) => {
const { t } = useI18n();
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
}, []);
const buildSessionSearchText = React.useCallback((session: Session): string => {
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
const sessionTitle = (session.title || 'Untitled Session').trim();
const sessionTitle = (session.title || t('sessions.sidebar.session.untitled')).trim();
return `${sessionTitle} ${sessionDirectory}`.toLowerCase();
}, []);
}, [t]);
const filterSessionNodesForSearch = React.useCallback(
(nodes: SessionNode[], query: string): SessionNode[] => {
@@ -142,7 +144,9 @@ export const useSessionGrouping = (args: Args) => {
const rootKey = normalizedProjectRoot ?? '__project_root__';
const groups: SessionGroup[] = [{
id: 'root',
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD') ? `project root: ${projectRootBranch}` : 'project root',
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD')
? t('sessions.sidebar.grouping.projectRootWithBranch', { branch: projectRootBranch })
: t('sessions.sidebar.grouping.projectRoot'),
branch: projectRootBranch ?? null,
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, args.homeDirectory) : null,
isMain: true,
@@ -223,9 +227,9 @@ export const useSessionGrouping = (args: Args) => {
if (archivedSessions.length > 0) {
groups.push({
id: 'archived',
label: 'archived',
label: t('sessions.sidebar.grouping.archived'),
branch: null,
description: 'Archived and unassigned sessions',
description: t('sessions.sidebar.grouping.archivedDescription'),
isMain: false,
isArchivedBucket: true,
worktree: null,
@@ -237,7 +241,7 @@ export const useSessionGrouping = (args: Args) => {
return groups;
},
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitBranches, args.isVSCode],
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitBranches, args.isVSCode, t],
);
return {
@@ -21,6 +21,7 @@ import {
import { cn } from '@/lib/utils';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
export interface SortableProjectItemProps {
id: string;
@@ -82,6 +83,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
openSidebarMenuKey,
setOpenSidebarMenuKey,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const {
attributes,
@@ -231,13 +233,13 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
)}
aria-label="New worktree"
aria-label={t('sessions.sidebar.project.actions.newWorktree')}
>
<RiNodeTree className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New worktree...</p>
<p>{t('sessions.sidebar.project.actions.newWorktreeEllipsis')}</p>
</TooltipContent>
</Tooltip>
) : null}
@@ -257,7 +259,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
? 'opacity-100'
: 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
)}
aria-label="Project menu"
aria-label={t('sessions.sidebar.project.actions.projectMenu')}
onPointerDown={handleMenuTriggerPointerDown}
onMouseDown={handleMenuTriggerMouseDown}
onClick={handleMenuTriggerClick}
@@ -269,19 +271,19 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
<DropdownMenuItem onClick={onNewSession}>
<RiAddLine className="mr-1.5 h-4 w-4" />
New Session
{t('sessions.sidebar.project.actions.newSession')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onRenameStart}>
<RiPencilAiLine className="mr-1.5 h-4 w-4" />
Rename
{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<RiCloseLine className="mr-1.5 h-4 w-4" />
Close Project
{t('sessions.sidebar.project.actions.closeProject')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -301,13 +303,17 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
)}
aria-label={isRepo ? 'New draft session' : 'New session'}
aria-label={isRepo
? t('sessions.sidebar.project.actions.newDraftSession')
: t('sessions.sidebar.project.actions.newSession')}
>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{isRepo ? 'New draft session' : 'New session'}</p>
<p>{isRepo
? t('sessions.sidebar.project.actions.newDraftSession')
: t('sessions.sidebar.project.actions.newSession')}</p>
</TooltipContent>
</Tooltip>
</div>