diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 47fa23b8..51d3a169 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -27,7 +27,15 @@ import { useSlashCommands } from './useSlashCommands'; import { useWorkspaceTarget, type WorkspaceCandidate } from './useWorkspaceTarget'; import { newQueuedDraftId, settleRetainedComposerSteer, useDurableComposerDraft } from './useDurableComposerDraft'; -interface UseChatComposerStateArgs { draftRepository?: ComposerDraftRepository; executionCwd?: string | null; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; sendMessage: (message: unknown) => boolean | void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onCommandGateChange?: (gate: PendingCommandGate | null) => void; onShowSettings?: () => void; onLogin?: (providerId?: string) => void; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch>; } +interface UseChatComposerStateArgs { draftRepository?: ComposerDraftRepository; executionCwd?: string | null; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; sendMessage: (message: unknown) => boolean | void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onCommandGateChange?: (gate: PendingCommandGate | null) => void; onShowSettings?: () => void; onLogin?: (providerId?: string) => void; + /** + * What the run-location picker shows before the user touches it. + * + * The caller owns this because the answer depends on whether the project is + * a git repository, which the composer never asks about. An untouched picker + * follows it; an explicit choice outranks it until the project changes. + */ + defaultUseWorktree?: boolean; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch>; } interface MentionableFile { name: string; path: string; } export type ModelCommandData = { current?: { provider?: string; providerLabel?: string; model?: string }; available?: Partial>; availableModels?: string[]; availableOptions?: Array<{ value: string; label?: string; description?: string }>; defaultModel?: string; cache?: ProviderModelsCacheInfo; }; export type CostCommandData = { tokenUsage?: { used?: number; total?: number }; tokenBreakdown?: { input?: number; output?: number }; provider?: string; model?: string; }; @@ -47,7 +55,7 @@ const resetBox = (setInput: (value: string) => void, value: MutableRefObject(null); + // `null` means "not chosen", which is different from "chose Project": only + // the former follows `defaultUseWorktree` when it resolves, and the choice is + // dropped when the project changes so one project's answer is not carried + // into another that may not even be a repository. + const [worktreeChoice, setWorktreeChoice] = useState(null); + const useWorktree = worktreeChoice ?? defaultUseWorktree; const [modelPickerTrigger, setModelPickerTrigger] = useState(0); const [pendingCommandGate, setGateState] = useState(null); const [queuePulse, setQueuePulse] = useState(0); @@ -110,6 +124,11 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { if (submissionOwner.current === owner) submissionOwner.current = null; }; }, [conversation, projectId]); + // A run location is chosen per project. Carrying one project's answer into + // the next would silently pick a location for a repository the user never + // looked at - and for a project that is not a repository at all, the + // worktree route would simply fail. + useEffect(() => { setWorktreeChoice(null); }, [projectId]); useEffect(() => { inputRef.current = input; }, [input]); useEffect(() => { liveImages.current = attachedImages; }, [attachedImages]); @@ -154,7 +173,10 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { if (!isCurrent()) return null; const project = target ? await descend(target) : selectedProject; if (!isCurrent()) return null; - const response = await authenticatedFetch('/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) }); + // The worktree route allocates the session and its managed checkout in one + // transaction; the ordinary route binds the session to the project itself. + // Both return the same `sessionId`, so nothing downstream branches on this. + const response = await authenticatedFetch(useWorktree ? '/api/providers/worktree-sessions' : '/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(typeof body.error === 'string' ? body.error : body.error?.message ?? `Failed to create session (${response.status})`); @@ -162,7 +184,7 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { id = (await response.json())?.data?.sessionId || null; if (!id) throw new Error('no session id returned.'); return { id, context: { provider: 'gjc', project: project!, summary } }; - }, [currentSessionId, descend, resolveForSend, selectedProject, selectedSession]); + }, [currentSessionId, descend, resolveForSend, selectedProject, selectedSession, useWorktree]); const handleSubmit = useCallback(async (event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, queued?: QueuedDraft) => { event.preventDefault(); const text = queued?.content ?? inputRef.current; if (!text.trim() || !selectedProject) return; @@ -415,5 +437,5 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { } finally { finishOperation(); } }, [sendMessage, setPendingPermissionRequests]); const handleInputFocusChange = useCallback((focused: boolean) => { setFocused(focused); onInputFocusChange?.(focused); }, [onInputFocusChange]); - return { composerFrozen, draftPersistence, draftReady, retryDraftPersistence, input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, attachmentNotice, dismissAttachmentNotice: () => setAttachmentNotice(null), getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget }; + return { useWorktree, setUseWorktree: setWorktreeChoice, composerFrozen, draftPersistence, draftReady, retryDraftPersistence, input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, attachmentNotice, dismissAttachmentNotice: () => setAttachmentNotice(null), getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget }; } diff --git a/src/components/chat/view/ChatComposer.tsx b/src/components/chat/view/ChatComposer.tsx index 5afd7fe1..6cba9f39 100644 --- a/src/components/chat/view/ChatComposer.tsx +++ b/src/components/chat/view/ChatComposer.tsx @@ -88,6 +88,8 @@ interface ChatComposerProps { isDragActive: boolean; /** Model pinned to this session, if any; outranks the last-run model. */ sessionPinnedModel?: string | null; + /** The run-location control, rendered first among the composer tools. */ + sessionLocationControl?: ReactNode; queuedDrafts: QueuedDraft[]; onEditQueuedDraft: (index: number) => void; onDeleteQueuedDraft: (index: number) => void; @@ -166,6 +168,7 @@ export default function ChatComposer({ onSteer, isDragActive, sessionPinnedModel, + sessionLocationControl, queuedDrafts, onEditQueuedDraft, onDeleteQueuedDraft, @@ -484,6 +487,8 @@ export default function ChatComposer({ can wrap separately when a split pane leaves too little room. */} + {sessionLocationControl} + >((id, context) => { setCurrentSessionId(id); onSessionEstablished?.(id, context); @@ -96,6 +106,10 @@ function ChatInterface({ const composer = useChatComposerState({ executionCwd: sessionLocation.data?.cwd, + // An unattended run commits, pushes and switches branches on whatever + // checkout it was given, so a repository session starts isolated and the + // user opts back into the shared checkout rather than out of it. + defaultUseWorktree: projectIsRepository, selectedProject, selectedSession, currentSessionId: session.currentSessionId, @@ -247,6 +261,9 @@ function ChatInterface({ onSteer={composer.handleSteer} isDragActive={composer.isDragActive} sessionPinnedModel={sessionPinnedModel} + sessionLocationControl={projectIsRepository || locationSessionId + ? + : null} queuedDrafts={composer.queuedDrafts} onEditQueuedDraft={composer.editQueuedDraft} onDeleteQueuedDraft={composer.deleteQueuedDraft} diff --git a/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx b/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx index 02930d1e..b274c26c 100644 --- a/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx +++ b/src/components/chat/view/SessionWorktreePicker.dom.bun.test.tsx @@ -1,16 +1,72 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { createElement } from 'react'; import { useChatComposerState } from '../hooks/useChatComposerState'; import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver'; import { useProjectGitSummary } from '../../workspace/hooks/useProjectGitSummary'; import { useProjectChanges } from '../../workspace/hooks/useProjectChanges'; +import SessionWorktreePicker from './SessionWorktreePicker'; + +/* + * Run location: the shared project checkout, or a managed worktree of it. + * + * The choice exists because an unattended run commits, pushes and switches + * branches on whatever checkout it was given, and a second session reading the + * same directory sees all of it happen underneath itself. + * + * The picker was deleted in a composer UI pass while the server route, the + * `session_worktrees` binding and their tests stayed live, which left the safe + * path reachable by nothing. These tests pin both halves: the control reports + * the choice, and the choice actually changes which route allocates the + * session. + */ + const originalFetch = globalThis.fetch; afterEach(() => { cleanup(); globalThis.fetch = originalFetch; localStorage.clear(); }); +const picker = (overrides: Partial[0]> = {}) => + createElement(SessionWorktreePicker, { value: false, onChange() {}, ...overrides }); + +test('the picker offers both locations and reports the one chosen', () => { + const chosen: boolean[] = []; + render(picker({ onChange: (value) => chosen.push(value) })); + + const select = screen.getByRole('combobox', { name: 'sessionWorktree.label' }) as HTMLSelectElement; + assert.equal(select.value, 'project'); + assert.deepEqual( + [...select.options].map((option) => option.value), + ['project', 'worktree'], + ); + + fireEvent.change(select, { target: { value: 'worktree' } }); + fireEvent.change(select, { target: { value: 'project' } }); + assert.deepEqual(chosen, [true, false]); +}); + +test('a session that already exists reports its location instead of offering a choice', () => { + // The location is fixed at creation: a running session cannot be moved, so a + // control here would offer something the server would refuse. + const { unmount } = render(picker({ value: true, sessionId: 'session-one', location: { mode: 'worktree', projectPath: '/repo', cwd: '/repo/.gjc-worktrees/job-one', jobId: 'job-one' } })); + assert.equal(screen.queryByRole('combobox'), null); + assert.ok(screen.getByText('sessionWorktree.worktree')); + unmount(); + + // Prepared but not yet on disk: say so rather than name a directory that is + // not there. + render(picker({ value: true, sessionId: 'session-one', location: { mode: 'worktree', projectPath: '/repo', cwd: null, jobId: 'job-one' } })); + assert.ok(screen.getByText('sessionWorktree.preparing')); +}); + +test('a project-bound session renders nothing at all', () => { + render(picker({ sessionId: 'session-one', location: { mode: 'project', projectPath: '/repo', cwd: '/repo', jobId: null } })); + assert.equal(screen.queryByRole('combobox'), null); + assert.equal(screen.queryByText('sessionWorktree.worktree'), null); +}); + test('composer creates through the ordinary route, then sends the allocated app identity', async () => { const requests: Array<{ url: string; body?: Record }> = []; const sent: unknown[] = []; @@ -35,10 +91,97 @@ test('composer creates through the ordinary route, then sends the allocated app await act(async () => { await view.result.current.handleSubmit({ preventDefault() {} } as never); }); const create = requests.find(({ url }) => url.endsWith('/providers/sessions')); assert.deepEqual(create?.body, { provider: 'gjc', projectPath: '/fixture/project' }); + // The project location was selected, so the worktree route must stay unused. assert.equal(requests.some(({ url }) => url.includes('/worktree-sessions')), false); assert.ok(sent.some((message) => (message as { type: string; sessionId: string }).type === 'chat.send' && (message as { sessionId: string }).sessionId === 'project-app-session')); }); +test('choosing the worktree location allocates through the worktree route', async () => { + const requests: Array<{ url: string; body?: Record }> = []; + const sent: unknown[] = []; + globalThis.fetch = (async (input, options) => { + const url = String(input); + requests.push({ url, ...(options?.body ? { body: JSON.parse(String(options.body)) } : {}) }); + const body = url.includes('/files') ? [] : url.includes('/worktree-sessions') + ? { success: true, data: { sessionId: 'worktree-app-session', projectPath: '/fixture/project', executionMode: 'worktree' } } + : { success: true, data: { commands: [], skills: [], isWorkspace: false, candidates: [] } }; + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as typeof fetch; + const view = renderHook(() => useChatComposerState({ + selectedProject: { projectId: 'project-one', fullPath: '/fixture/project', displayName: 'Project' }, + selectedSession: null, currentSessionId: null, gjcModel: 'openai-codex/gpt-6-astra', reasoningEffort: 'xhigh', + isLoading: false, canAbortSession: false, tokenBudget: null, + sendMessage: (message) => { sent.push(message); return true; }, scrollToBottom() {}, addMessage() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, + })); + + act(() => { view.result.current.setUseWorktree(true); }); + act(() => { + view.result.current.handleInputChange({ target: { value: 'fixture prompt', selectionStart: 14 } } as never); + }); + await act(async () => { await view.result.current.handleSubmit({ preventDefault() {} } as never); }); + + const create = requests.find(({ url }) => url.includes('/worktree-sessions')); + assert.deepEqual(create?.body, { provider: 'gjc', projectPath: '/fixture/project' }); + // Same payload, same returned identity shape: only the allocation route moves. + assert.equal(requests.some(({ url }) => url.endsWith('/providers/sessions')), false); + assert.ok(sent.some((message) => (message as { sessionId: string }).sessionId === 'worktree-app-session')); +}); + +test('an untouched picker follows the default, and an explicit choice outranks it', async () => { + const requests: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + requests.push(url); + const body = url.includes('/files') ? [] : url.includes('-sessions') + ? { success: true, data: { sessionId: 'allocated', projectPath: '/fixture/project' } } + : { success: true, data: { commands: [], skills: [], isWorkspace: false, candidates: [] } }; + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as typeof fetch; + const args = { + selectedProject: { projectId: 'project-one', fullPath: '/fixture/project', displayName: 'Project' }, + selectedSession: null, currentSessionId: null, gjcModel: 'openai-codex/gpt-6-astra', reasoningEffort: 'xhigh', + isLoading: false, canAbortSession: false, tokenBudget: null, + sendMessage: () => true, scrollToBottom() {}, addMessage() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, + }; + const view = renderHook(() => useChatComposerState({ ...args, defaultUseWorktree: true })); + + // Nothing was chosen, so a repository project starts isolated. + assert.equal(view.result.current.useWorktree, true); + + // Opting back into the shared checkout is a real choice and has to stick. + act(() => { view.result.current.setUseWorktree(false); }); + assert.equal(view.result.current.useWorktree, false); + + act(() => { + view.result.current.handleInputChange({ target: { value: 'fixture prompt', selectionStart: 14 } } as never); + }); + await act(async () => { await view.result.current.handleSubmit({ preventDefault() {} } as never); }); + assert.ok(requests.some((url) => url.endsWith('/providers/sessions'))); + assert.equal(requests.some((url) => url.includes('/worktree-sessions')), false); +}); + +test('a choice made for one project is not carried into the next', async () => { + globalThis.fetch = (async () => new Response('[]', { status: 200 })) as typeof fetch; + const base = { + selectedSession: null, currentSessionId: null, gjcModel: 'openai-codex/gpt-6-astra', reasoningEffort: 'xhigh', + isLoading: false, canAbortSession: false, tokenBudget: null, + sendMessage: () => true, scrollToBottom() {}, addMessage() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, + }; + const view = renderHook(({ projectId }: { projectId: string }) => useChatComposerState({ + ...base, + selectedProject: { projectId, fullPath: `/fixture/${projectId}`, displayName: projectId }, + defaultUseWorktree: true, + }), { initialProps: { projectId: 'project-one' } }); + + act(() => { view.result.current.setUseWorktree(false); }); + assert.equal(view.result.current.useWorktree, false); + + // The next project gets its own answer: the previous "no" said nothing about + // a repository the user has not looked at yet. + view.rerender({ projectId: 'project-two' }); + await waitFor(() => assert.equal(view.result.current.useWorktree, true)); +}); + test('file references resolve through the selected session to its worktree', async () => { const requests: string[] = []; const opened: string[] = []; diff --git a/src/components/chat/view/SessionWorktreePicker.tsx b/src/components/chat/view/SessionWorktreePicker.tsx new file mode 100644 index 00000000..4628ec3e --- /dev/null +++ b/src/components/chat/view/SessionWorktreePicker.tsx @@ -0,0 +1,41 @@ +import { GitBranch } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import type { SessionLocation } from '../hooks/useSessionLocation'; + +type Props = { + value: boolean; + onChange: (enabled: boolean) => void; + sessionId?: string | null; + location?: SessionLocation; + disabled?: boolean; +}; + +/** Select once, before session creation; existing sessions retain their location. */ +export default function SessionWorktreePicker({ value, onChange, sessionId, location, disabled }: Props) { + const { t } = useTranslation('chat'); + if (sessionId) { + if (location?.mode !== 'worktree') return null; + return ( + + + {location.cwd ? t('sessionWorktree.worktree') : t('sessionWorktree.preparing')} + + ); + } + return ( + + ); +} diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index fa98efff..aaa58c9a 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -274,5 +274,12 @@ "partial": "Teilweise Übereinstimmung", "recent": "Kürzlich aktiv" } + }, + "sessionWorktree": { + "label": "Ausführungsort", + "project": "Projekt", + "newWorktree": "Neuer Worktree", + "worktree": "Worktree", + "preparing": "Worktree wird vorbereitet" } } diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 00b6f4b8..f725a6a3 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -274,5 +274,12 @@ "partial": "Partial match", "recent": "Recently active" } + }, + "sessionWorktree": { + "label": "Run location", + "project": "Project", + "newWorktree": "New worktree", + "worktree": "Worktree", + "preparing": "Preparing worktree" } } diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index b96102aa..64bc509c 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -274,5 +274,12 @@ "partial": "Correspondance partielle", "recent": "Récemment actif" } + }, + "sessionWorktree": { + "label": "Dossier d’exécution", + "project": "Projet", + "newWorktree": "Nouveau worktree", + "worktree": "Worktree", + "preparing": "Préparation du worktree" } } diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index 9f1b2efc..85a12c83 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -274,5 +274,12 @@ "partial": "Corrispondenza parziale", "recent": "Attivo di recente" } + }, + "sessionWorktree": { + "label": "Directory di esecuzione", + "project": "Progetto", + "newWorktree": "Nuovo worktree", + "worktree": "Worktree", + "preparing": "Preparazione del worktree" } } diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 7fc025bf..ea879a5a 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -274,5 +274,12 @@ "partial": "部分一致", "recent": "最近の活動" } + }, + "sessionWorktree": { + "label": "実行場所", + "project": "プロジェクト", + "newWorktree": "新しいワークツリー", + "worktree": "ワークツリー", + "preparing": "ワークツリーを準備中" } } diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 26ea75a2..5616aad4 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -274,5 +274,12 @@ "partial": "부분 일치", "recent": "최근 활동" } + }, + "sessionWorktree": { + "label": "실행 위치", + "project": "프로젝트", + "newWorktree": "새 워크트리", + "worktree": "워크트리", + "preparing": "워크트리 준비 중" } } diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 2629c12c..bfe0980a 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -294,5 +294,12 @@ "partial": "Частичное совпадение", "recent": "Недавняя активность" } + }, + "sessionWorktree": { + "label": "Место выполнения", + "project": "Проект", + "newWorktree": "Новое рабочее дерево", + "worktree": "Рабочее дерево", + "preparing": "Подготовка рабочего дерева" } } diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index 9fa2d172..38169440 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -274,5 +274,12 @@ "partial": "Kısmi eşleşme", "recent": "Son etkinlik" } + }, + "sessionWorktree": { + "label": "Çalışma konumu", + "project": "Proje", + "newWorktree": "Yeni çalışma ağacı", + "worktree": "Çalışma ağacı", + "preparing": "Çalışma ağacı hazırlanıyor" } } diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index a7aa0294..63abbd1a 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -274,5 +274,12 @@ "partial": "部分匹配", "recent": "最近活跃" } + }, + "sessionWorktree": { + "label": "运行位置", + "project": "项目", + "newWorktree": "新建工作树", + "worktree": "工作树", + "preparing": "正在准备工作树" } } diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index 80aec56f..e9752db7 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -274,5 +274,12 @@ "partial": "部分符合", "recent": "最近活躍" } + }, + "sessionWorktree": { + "label": "執行位置", + "project": "專案", + "newWorktree": "新增工作樹", + "worktree": "工作樹", + "preparing": "正在準備工作樹" } }