From 84e337c8a8ad02bd0d4bae05141f411f12517c2c Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:51:51 +0900 Subject: [PATCH] feat(chat): restore the run-location picker and isolate repository sessions by default `565c741` shipped managed session worktrees: a route, a `session_worktrees` binding, a runtime and 20 tests, all still passing today. `32da9da`, a composer UI pass, deleted `SessionWorktreePicker.tsx` and the ten locales' wording in the same commit as unrelated control tweaks, with a single-line message and no recorded reason. The server kept answering `POST /providers/worktree-sessions`; nothing could ask it. `session_worktrees` has been empty ever since. That is a fail-open. An unattended run commits, pushes and switches branches on whatever checkout it was given, and a second session reading the same directory watches its branch and index change underneath it. The safe path existed, was tested, and was unreachable. This restores the picker verbatim and reconnects it, and changes one thing: a project that is a git repository now defaults to a new worktree instead of the shared checkout. Opting back into the shared checkout stays one click away, and the choice is per project - `null` means "not chosen" and follows the default, so a "no" for one repository is never carried into the next, which may not be a repository at all. `useProjectGitSummary` is the only thing here that knows, and until it answers the picker stays on the project so the worktree route is never called where it would fail. The picker's test file survived the deletion with its name and lost its subject: it tested the composer instead and asserted `/worktree-sessions` is never called, pinning the shared-checkout behaviour under the deleted component's name. It now tests the control, both routes, the default, an explicit choice outranking it, and the per-project reset. Refs #156 --- .../chat/hooks/useChatComposerState.ts | 32 +++- src/components/chat/view/ChatComposer.tsx | 5 + src/components/chat/view/ChatInterface.tsx | 19 ++- .../SessionWorktreePicker.dom.bun.test.tsx | 145 +++++++++++++++++- .../chat/view/SessionWorktreePicker.tsx | 41 +++++ src/i18n/locales/de/chat.json | 7 + src/i18n/locales/en/chat.json | 7 + src/i18n/locales/fr/chat.json | 7 + src/i18n/locales/it/chat.json | 7 + src/i18n/locales/ja/chat.json | 7 + src/i18n/locales/ko/chat.json | 7 + src/i18n/locales/ru/chat.json | 7 + src/i18n/locales/tr/chat.json | 7 + src/i18n/locales/zh-CN/chat.json | 7 + src/i18n/locales/zh-TW/chat.json | 7 + 15 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 src/components/chat/view/SessionWorktreePicker.tsx 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": "正在準備工作樹" } }