feat: add search to the new session project picker (#3408)
* feat: add search to the new session project picker * fix: punctuate the project picker empty state
This commit is contained in:
@@ -189,7 +189,14 @@ and the send path reading the same grammar.
|
||||
also owns the advisory dirty state for the selected directory, clearing it as
|
||||
soon as the target changes so a warning never names a previous branch.
|
||||
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
||||
state and registers its application shortcuts locally. The selectors only
|
||||
state and registers its application shortcuts locally. The desktop project
|
||||
picker is a searchable popup: it ranks the current projects with
|
||||
`rankByQuery` over display label and path, keeps the query and the active
|
||||
result as transient local state that resets on every close, and commits
|
||||
through the existing project-change flow only on explicit activation.
|
||||
Filtering changes the result area below the anchored input without moving
|
||||
the search field. The
|
||||
worktree Select and the mobile bottom sheets are unchanged. The selectors only
|
||||
consume their shared prefix while the draft target UI is mounted.
|
||||
|
||||
## Input recall ownership
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Where a new session will run: the project and the directory within it.
|
||||
*
|
||||
* Desktop uses inline selects; mobile uses bottom sheets, because a native
|
||||
* select over a keyboard-resized viewport is unusable. Both render the same
|
||||
* options from the same hook, and both offer creating a worktree inline so the
|
||||
* user does not have to leave the draft to make one.
|
||||
* Desktop uses a searchable project popup and an inline branch/worktree
|
||||
* select; mobile uses bottom sheets, because a native select over a
|
||||
* keyboard-resized viewport is unusable. Both render the same options from
|
||||
* the same hook, and both offer creating a worktree inline so the user does
|
||||
* not have to leave the draft to make one.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -12,8 +13,19 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||
import { Popover } from '@base-ui/react/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles';
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { handleDropdownNavigationKey, shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -28,6 +40,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||
import { shortcutRegistry } from '@/lib/shortcuts';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
@@ -145,8 +158,34 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
theme,
|
||||
} = props;
|
||||
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
|
||||
const [projectQuery, setProjectQuery] = React.useState('');
|
||||
const [projectActiveId, setProjectActiveId] = React.useState<string | null>(null);
|
||||
const [projectFocusReturn, setProjectFocusReturn] = React.useState(false);
|
||||
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const projectSearchRef = React.useRef<HTMLInputElement>(null);
|
||||
// Preserve Select's dialog portal and main-area containment.
|
||||
const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
const [projectCollisionBoundary, setProjectCollisionBoundary] = React.useState<Element | null>(null);
|
||||
const syncProjectPopupContainers = React.useCallback((target: EventTarget | null) => {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
const dialog = element?.closest('[data-slot="dialog-content"], [role="dialog"]');
|
||||
setProjectPortalContainer(dialog instanceof HTMLElement ? dialog : null);
|
||||
setProjectCollisionBoundary(element?.closest('main') ?? null);
|
||||
}, []);
|
||||
// The popover owns no shortcut suspension (unlike Select/DropdownMenu
|
||||
// wrappers), so suspend global shortcuts while the project popup is
|
||||
// open and restore them on close/unmount.
|
||||
const projectSuspendRef = React.useRef<(() => void) | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (openPicker !== 'project') return;
|
||||
projectSuspendRef.current?.();
|
||||
projectSuspendRef.current = shortcutRegistry.suspend();
|
||||
return () => {
|
||||
projectSuspendRef.current?.();
|
||||
projectSuspendRef.current = null;
|
||||
};
|
||||
}, [openPicker]);
|
||||
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (openPicker === null || !shouldDismissDropdown(event)) return;
|
||||
event.preventDefault();
|
||||
@@ -156,6 +195,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
|
||||
useKeybind('open_draft_project_picker', () => {
|
||||
projectTriggerRef.current?.focus();
|
||||
setProjectFocusReturn(false);
|
||||
setProjectQuery('');
|
||||
setProjectActiveId(
|
||||
projects.some((project) => project.id === selectedProject.id) ? selectedProject.id : null,
|
||||
);
|
||||
setOpenPicker('project');
|
||||
});
|
||||
useKeybind('open_draft_worktree_picker', () => {
|
||||
@@ -164,11 +208,43 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
setOpenPicker('worktree');
|
||||
});
|
||||
|
||||
const filteredProjects = React.useMemo(
|
||||
() => openPicker === 'project'
|
||||
? rankByQuery(projects, projectQuery, (project) => [getProjectDisplayLabel(project), project.path])
|
||||
: projects,
|
||||
[openPicker, projects, projectQuery],
|
||||
);
|
||||
|
||||
// Transient search must never survive to the next opening, including
|
||||
// picker switches and draft unmounts.
|
||||
React.useEffect(() => {
|
||||
if (openPicker !== 'project') {
|
||||
setProjectQuery('');
|
||||
setProjectActiveId(null);
|
||||
}
|
||||
}, [openPicker]);
|
||||
// After a query or project-list change, retain the active ID only
|
||||
// while it stays visible; otherwise fall to the first result (or none
|
||||
// when the list is empty) so Enter cannot commit a hidden row.
|
||||
React.useEffect(() => {
|
||||
if (openPicker !== 'project') return;
|
||||
setProjectActiveId((current) => {
|
||||
if (current && filteredProjects.some((project) => project.id === current)) return current;
|
||||
return filteredProjects[0]?.id ?? null;
|
||||
});
|
||||
}, [filteredProjects, openPicker]);
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
onProjectChange(projectId);
|
||||
setProjectFocusReturn(true);
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
const handleProjectSelect = (projectId: string) => {
|
||||
if (!filteredProjects.some((project) => project.id === projectId)) return;
|
||||
handleProjectChange(projectId);
|
||||
};
|
||||
|
||||
const handleDirectoryChange = (directory: string) => {
|
||||
onDirectoryChange(directory);
|
||||
setOpenPicker(null);
|
||||
@@ -176,33 +252,152 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedProject.id}
|
||||
{/* Plain popover (not a menu): the popup owns a combobox +
|
||||
listbox, so menu/menuitem semantics would be wrong here. */}
|
||||
<Popover.Root
|
||||
open={openPicker === 'project'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
|
||||
onValueChange={handleProjectChange}
|
||||
disableGlobalShortcuts
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
if (open) {
|
||||
setProjectFocusReturn(false);
|
||||
// Seed the active row from the committed project so
|
||||
// Enter without typing repeats the current choice.
|
||||
// The clamp below keeps it when still visible and
|
||||
// falls to the first result otherwise.
|
||||
setProjectQuery('');
|
||||
setProjectActiveId(
|
||||
projects.some((project) => project.id === selectedProject.id)
|
||||
? selectedProject.id
|
||||
: null,
|
||||
);
|
||||
setOpenPicker('project');
|
||||
return;
|
||||
}
|
||||
setProjectQuery('');
|
||||
setProjectActiveId(null);
|
||||
const reason = eventDetails?.reason;
|
||||
setProjectFocusReturn(reason === 'escape-key');
|
||||
setOpenPicker(null);
|
||||
}}
|
||||
onOpenChangeComplete={(open) => {
|
||||
// Return focus after Base UI finishes closing so the
|
||||
// trigger itself, not document body, keeps keyboard flow.
|
||||
if (!open && projectFocusReturn) {
|
||||
projectTriggerRef.current?.focus();
|
||||
setProjectFocusReturn(false);
|
||||
}
|
||||
// Focus the search once the popup mounts; the opening
|
||||
// shortcut focuses the trigger first.
|
||||
if (open && openPicker === 'project') projectSearchRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={projectTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<Button
|
||||
ref={projectTriggerRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-haspopup="dialog"
|
||||
className="h-7 min-w-0 w-fit max-w-[42vw] justify-start gap-1 px-1.5 normal-case sm:max-w-[18rem]"
|
||||
onPointerDownCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
|
||||
onFocusCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{selectedProject.kind === 'chat'
|
||||
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
|
||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
<ProjectLabel project={project} theme={theme} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
|
||||
</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal container={projectPortalContainer ?? undefined}>
|
||||
{/* side="bottom" anchors the popup's top edge at the
|
||||
trigger: the search field stays fixed at the
|
||||
selector's level while filtering, and only the
|
||||
results area below changes height. Disabling side
|
||||
flips keeps the input stationary during filtering.
|
||||
Horizontal shifting keeps the popup inside main. The
|
||||
--available-height cap comes free from the shared
|
||||
popup class, so the list scrolls within the space
|
||||
below the trigger. */}
|
||||
<Popover.Positioner
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionAvoidance={{ side: 'none' }}
|
||||
collisionBoundary={projectCollisionBoundary ?? undefined}
|
||||
className="app-region-no-drag z-50"
|
||||
>
|
||||
<Popover.Popup
|
||||
role="dialog"
|
||||
aria-label={t('chat.chatInput.draftPicker.projectTitle')}
|
||||
className={cn(dropdownMenuPopupClass, 'flex w-72 max-w-[calc(100vw-2rem)] flex-col p-0')}
|
||||
initialFocus={false}
|
||||
finalFocus={false}
|
||||
>
|
||||
{/* Filtering and ordering are owned by rankByQuery above;
|
||||
cmdk's own filter would re-filter and reorder the
|
||||
already-ranked rows. */}
|
||||
<Command
|
||||
className="min-h-0 flex-1"
|
||||
shouldFilter={false}
|
||||
value={projectActiveId ?? undefined}
|
||||
onValueChange={setProjectActiveId}
|
||||
>
|
||||
<CommandInput
|
||||
ref={projectSearchRef}
|
||||
aria-label={t('chat.chatInput.draftPicker.searchProjects')}
|
||||
placeholder={t('chat.chatInput.draftPicker.searchProjects')}
|
||||
value={projectQuery}
|
||||
onValueChange={setProjectQuery}
|
||||
onKeyDown={(event) => {
|
||||
// Command owns active-item navigation, so only
|
||||
// translate the repository's Ctrl+N/P
|
||||
// convention into arrows at the input.
|
||||
// IME-composing keys must never move the
|
||||
// active row or dismiss the popup.
|
||||
if (isIMECompositionEvent(event)) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
handleDropdownNavigationKey(event, (navigationKey) => {
|
||||
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: navigationKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CommandList label={t('chat.chatInput.draftPicker.projectTitle')}>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div role="status" className="px-3 py-6 text-center typography-ui-label text-muted-foreground">
|
||||
{t('chat.chatInput.draftPicker.noProjectsFound')}
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={project.id}
|
||||
onSelect={handleProjectSelect}
|
||||
aria-current={project.id === selectedProject.id ? true : undefined}
|
||||
className="max-w-full"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<ProjectLabel project={project} theme={theme} />
|
||||
</span>
|
||||
{project.id === selectedProject.id ? (
|
||||
<Icon name="check" className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
{showBranchSelector ? (
|
||||
<Select
|
||||
|
||||
@@ -2185,6 +2185,7 @@ export const dict = {
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Projekt',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Projekte durchsuchen...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Branches durchsuchen...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': 'Keine Projekte gefunden.',
|
||||
'chat.chatInput.worktrees': 'Worktrees',
|
||||
'chat.chatInput.worktreeNew': '+ Neu',
|
||||
'chat.chatInput.drop.insertMention': 'Hier ablegen, um als Erwähnung einzufügen',
|
||||
|
||||
@@ -2406,6 +2406,7 @@ export const dict = {
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Project',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Search projects...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Search branches...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': 'No projects found.',
|
||||
'chat.chatInput.worktrees': 'Worktrees',
|
||||
'chat.chatInput.worktreeNew': '+ New',
|
||||
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
|
||||
|
||||
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.draftPicker.projectTitle": "Proyecto",
|
||||
"chat.chatInput.draftPicker.searchProjects": "Buscar proyectos...",
|
||||
"chat.chatInput.draftPicker.searchBranches": "Buscar ramas...",
|
||||
"chat.chatInput.draftPicker.noProjectsFound": "No se encontraron proyectos.",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
"chat.chatInput.worktreeNew": "+ Nuevo",
|
||||
"chat.chatInput.drop.insertMention": "Suelta para insertar como mención",
|
||||
|
||||
@@ -2114,6 +2114,7 @@ export const dict = {
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Projet',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Rechercher des projets...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Rechercher des branches...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': 'Aucun projet trouvé.',
|
||||
'chat.chatInput.worktrees': 'Worktrees',
|
||||
'chat.chatInput.worktreeNew': '+ Nouveau',
|
||||
'chat.chatInput.drop.insertMention': 'Déposer pour insérer comme mention',
|
||||
|
||||
@@ -2405,6 +2405,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.draftPicker.projectTitle': 'プロジェクト',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'プロジェクトを検索...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'ブランチを検索...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': 'プロジェクトが見つかりません。',
|
||||
'chat.chatInput.worktrees': 'ワークツリー',
|
||||
'chat.chatInput.worktreeNew': '+ 新規',
|
||||
'chat.chatInput.drop.insertMention': 'ドロップしてメンションとして挿入',
|
||||
|
||||
@@ -2406,6 +2406,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.draftPicker.projectTitle': '프로젝트',
|
||||
'chat.chatInput.draftPicker.searchProjects': '프로젝트 검색...',
|
||||
'chat.chatInput.draftPicker.searchBranches': '브랜치 검색...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': '프로젝트를 찾을 수 없습니다.',
|
||||
'chat.chatInput.worktrees': '워크트리',
|
||||
'chat.chatInput.worktreeNew': '+ 새로 만들기',
|
||||
'chat.chatInput.drop.insertMention': '여기에 놓아 멘션으로 추가',
|
||||
|
||||
@@ -1267,6 +1267,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Projekt',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Szukaj projektów...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Szukaj gałęzi...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': 'Nie znaleziono projektów.',
|
||||
'chat.chatInput.drop.attachFiles': 'Drop files here to attach',
|
||||
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
|
||||
'chat.chatInput.fileFallback': 'file',
|
||||
|
||||
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.draftPicker.projectTitle": "Projeto",
|
||||
"chat.chatInput.draftPicker.searchProjects": "Buscar projetos...",
|
||||
"chat.chatInput.draftPicker.searchBranches": "Buscar branches...",
|
||||
"chat.chatInput.draftPicker.noProjectsFound": "Nenhum projeto encontrado.",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
"chat.chatInput.worktreeNew": "+ Novo",
|
||||
"chat.chatInput.drop.insertMention": "Solte para inserir como menção",
|
||||
|
||||
@@ -2346,6 +2346,7 @@ export const dict = {
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Proje',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Projelerde ara...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Branch\'lerde ara...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': 'Proje bulunamadı.',
|
||||
'chat.chatInput.worktrees': 'Worktree\'ler',
|
||||
'chat.chatInput.worktreeNew': '+ Yeni',
|
||||
'chat.chatInput.drop.insertMention': 'Mention olarak eklemek için bırak',
|
||||
|
||||
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.draftPicker.projectTitle": "Проєкт",
|
||||
"chat.chatInput.draftPicker.searchProjects": "Пошук проєктів...",
|
||||
"chat.chatInput.draftPicker.searchBranches": "Пошук гілок...",
|
||||
"chat.chatInput.draftPicker.noProjectsFound": "Проєктів не знайдено.",
|
||||
"chat.chatInput.worktrees": "Worktree",
|
||||
"chat.chatInput.worktreeNew": "+ Новий",
|
||||
"chat.chatInput.drop.insertMention": "Відпустіть, щоб вставити як згадку",
|
||||
|
||||
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.draftPicker.projectTitle': '项目',
|
||||
'chat.chatInput.draftPicker.searchProjects': '搜索项目...',
|
||||
'chat.chatInput.draftPicker.searchBranches': '搜索分支...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': '未找到项目。',
|
||||
'chat.chatInput.worktrees': '工作树',
|
||||
'chat.chatInput.worktreeNew': '+ 新建',
|
||||
'chat.chatInput.drop.insertMention': '释放以插入为提及',
|
||||
|
||||
@@ -2376,6 +2376,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.draftPicker.projectTitle': '專案',
|
||||
'chat.chatInput.draftPicker.searchProjects': '搜尋專案...',
|
||||
'chat.chatInput.draftPicker.searchBranches': '搜尋分支...',
|
||||
'chat.chatInput.draftPicker.noProjectsFound': '找不到專案。',
|
||||
'chat.chatInput.worktrees': 'Worktree',
|
||||
'chat.chatInput.worktreeNew': '+ 新增',
|
||||
'chat.chatInput.drop.insertMention': '放開以插入為提及',
|
||||
|
||||
Reference in New Issue
Block a user