Add i18n foundation and translations (#1027)
* feat: add i18n foundation * feat: localize sessions sidebar * Localize multirun/scheduled tasks and fix dialog dropdown interactions * localize git sidebar surface and add zh-CN keys * feat(ui): localize context panel, diff/plan views, and context sidebar content * fix(config): resolve user config home via fs/home before embedded home * localize header/chat UI and complete model/worktree panel strings * localize worktree + github issue/pr dialog flows * localize settings sections and split settings i18n dictionaries * localize additional settings sections and sidebars * localize more settings pages and dialogs * fix settings select trigger localization * localize tunnel settings ui surface * localize additional settings sections * localize keyboard shortcuts labels in settings * localize terminal and utility dialogs surfaces * feat(i18n): localize remaining UI strings * Add Ukrainian locale * Add Spanish locale * Add Brazilian Portuguese locale * Polish locale translations
This commit is contained in:
committed by
GitHub
parent
87db2ea210
commit
7d7285655d
@@ -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-<number>-<slug>)</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>
|
||||
|
||||
Reference in New Issue
Block a user