From 70bd9d79bc09236c8525df82096262d60ecc1796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Lee-Fr=C3=B6lich?= Date: Tue, 25 Aug 2026 05:00:56 +0000 Subject: [PATCH] fix(desktop): honor remote working directory for new chats and allow typed paths (#11322) Co-authored-by: pcace <{ID}+{username}@users.noreply.github.com> --- ui/desktop/src/components/Hub.tsx | 28 +++++- .../components/bottom_menu/DirSwitcher.tsx | 51 ++++++++++- ui/desktop/src/i18n/messages/de.json | 10 +++ ui/desktop/src/i18n/messages/en.json | 9 ++ ui/desktop/src/i18n/messages/es.json | 11 +++ ui/desktop/src/i18n/messages/fr.json | 11 +++ ui/desktop/src/i18n/messages/hi.json | 11 +++ ui/desktop/src/i18n/messages/id.json | 11 +++ ui/desktop/src/i18n/messages/it.json | 11 +++ ui/desktop/src/i18n/messages/ja.json | 11 +++ ui/desktop/src/i18n/messages/ko.json | 11 +++ ui/desktop/src/i18n/messages/ms.json | 11 +++ ui/desktop/src/i18n/messages/pt.json | 11 +++ ui/desktop/src/i18n/messages/ru.json | 11 +++ ui/desktop/src/i18n/messages/tr.json | 11 +++ ui/desktop/src/i18n/messages/vi.json | 11 +++ ui/desktop/src/i18n/messages/zh-CN.json | 11 +++ ui/desktop/src/i18n/messages/zh-TW.json | 11 +++ ui/desktop/src/main.ts | 8 ++ .../src/utils/__tests__/workingDir.test.ts | 88 ++++++++++++++++++- ui/desktop/src/utils/workingDir.ts | 49 +++++++++++ 21 files changed, 389 insertions(+), 8 deletions(-) diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 873af656c..46e684d4d 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -16,7 +16,7 @@ import { ChatState } from '../types/chatState'; import 'react-toastify/dist/ReactToastify.css'; import { View, ViewOptions } from '../utils/navigationUtils'; import { useConfig } from './ConfigContext'; -import { getInitialWorkingDir } from '../utils/workingDir'; +import { getEffectiveWorkingDir, getInitialWorkingDir } from '../utils/workingDir'; import { createSession } from '../sessions'; import LoadingGoose from './LoadingGoose'; import { UserInput } from '../types/message'; @@ -57,12 +57,25 @@ export default function Hub({ const intl = useIntl(); const { extensionsList } = useConfig(); const [workingDir, setWorkingDir] = useState(getInitialWorkingDir()); + const userSelectedWorkingDirRef = useRef(false); const [isCreatingSession, setIsCreatingSession] = useState(false); const [nextChatExtensionDraft, setNextChatExtensionDraft] = useState(null); const inputRef = useRef(null); const { time, meridiem, hour } = useClock(); + // Re-resolve the working dir on mount: GOOSE_WORKING_DIR is fixed at window + // creation, so a configured remote directory may have changed since then. + useEffect(() => { + let active = true; + void getEffectiveWorkingDir().then((dir) => { + if (active && !userSelectedWorkingDirRef.current) setWorkingDir(dir); + }); + return () => { + active = false; + }; + }, []); + const greeting = useMemo(() => { if (hour < 12) return intl.formatMessage(i18n.goodMorning); if (hour < 18) return intl.formatMessage(i18n.goodAfternoon); @@ -86,6 +99,11 @@ export default function Hub({ setNextChatExtensionDraft(draft); }, []); + const handleWorkingDirChange = useCallback((dir: string) => { + userSelectedWorkingDirRef.current = true; + setWorkingDir(dir); + }, []); + const handleSubmit = async (input: UserInput) => { const { msg: userMessage, images } = input; if (!(images.length > 0 || userMessage.trim()) || isCreatingSession) return; @@ -101,7 +119,10 @@ export default function Hub({ ? { extensionConfigs: selectedExtensions } : { allExtensions: extensionsList }; - const session = await createSession(workingDir, sessionOptions); + // Resolve the effective directory at submit time: the IPC lookup may still + // be pending when the user submits, and an explicit pick must win. + const dir = userSelectedWorkingDirRef.current ? workingDir : await getEffectiveWorkingDir(); + const session = await createSession(dir, sessionOptions); setNextChatExtensionDraft(null); window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); @@ -149,7 +170,8 @@ export default function Hub({ onFilesProcessed={() => {}} messages={[]} disableAnimation={false} - onWorkingDirChange={setWorkingDir} + workingDir={workingDir} + onWorkingDirChange={handleWorkingDirChange} inputRef={inputRef} nextChatExtensionDraft={draftForMenu} onNextChatExtensionDraftChange={handleNextChatExtensionDraftChange} diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index f269e00e5..ea7855dca 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Check, FolderDot, FolderOpen, GitBranch, Plus } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; +import { Input } from '../ui/input'; import { DropdownMenu, DropdownMenuContent, @@ -41,8 +42,23 @@ const i18n = defineMessages({ id: 'dirSwitcher.noWorktreesFound', defaultMessage: 'No worktrees found', }, + enterPath: { + id: 'dirSwitcher.enterPath', + defaultMessage: 'Enter path', + }, + enterPathPlaceholder: { + id: 'dirSwitcher.enterPathPlaceholder', + defaultMessage: 'Enter an absolute path (e.g. /home/goose/workspace)', + }, + enterPathInvalid: { + id: 'dirSwitcher.enterPathInvalid', + defaultMessage: 'Working directory must be an absolute path', + }, }); +const isAbsolutePath = (p: string): boolean => + p.startsWith('/') || p.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(p); + const splitDirPath = (dir: string): { name: string; parent: string } => { const normalized = dir.replace(/[\\/]+$/, ''); const parts = normalized.split(/[\\/]/); @@ -84,6 +100,7 @@ export const DirSwitcher: React.FC = ({ const [isMenuOpen, setIsMenuOpen] = useState(false); const [recentDirs, setRecentDirs] = useState([]); const [worktreeDirs, setWorktreeDirs] = useState([]); + const [customDirInput, setCustomDirInput] = useState(''); const refreshVersionRef = useRef(0); const refreshMenuData = useCallback(async () => { @@ -111,9 +128,6 @@ export const DirSwitcher: React.FC = ({ }, [isMenuOpen, refreshMenuData]); const applyDirectoryChange = async (newDir: string) => { - window.electron.addRecentDir(newDir); - setRecentDirs((previous) => [newDir, ...previous.filter((dir) => dir !== newDir)].slice(0, 10)); - if (sessionId) { onRestartStart?.(); @@ -122,12 +136,18 @@ export const DirSwitcher: React.FC = ({ } catch (error) { console.error('[DirSwitcher] Failed to update working directory:', error); toast.error(intl.formatMessage(i18n.failedToUpdateWorkingDir)); + return; } finally { onRestartEnd?.(); } } else { await onWorkingDirChange?.(newDir); } + + // Only record the directory after the backend confirmed the change, so a + // rejected path does not pollute the recent-directories list. + window.electron.addRecentDir(newDir); + setRecentDirs((previous) => [newDir, ...previous.filter((dir) => dir !== newDir)].slice(0, 10)); }; const handleDirectoryChange = async () => { @@ -218,6 +238,31 @@ export const DirSwitcher: React.FC = ({ + + {intl.formatMessage(i18n.enterPath)} +
+ setCustomDirInput(e.target.value)} + onKeyDown={(e) => { + // Stop Radix menu typeahead from stealing focus while typing + // a path (e.g. the initial "C" of a Windows drive path). + e.stopPropagation(); + if (e.key === 'Enter' && customDirInput.trim()) { + const newDir = customDirInput.trim(); + if (!isAbsolutePath(newDir)) { + toast.error(intl.formatMessage(i18n.enterPathInvalid)); + return; + } + setCustomDirInput(''); + setIsMenuOpen(false); + void applyDirectoryChange(newDir); + } + }} + /> +
+ {intl.formatMessage(i18n.gitWorktrees)} {filteredWorktreeDirs.length > 0 ? ( diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index e88e35587..e7226b5ce 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -917,6 +917,16 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Aktuelles Verzeichnis" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Pfad eingeben" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Absoluten Pfad eingeben (z. B. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Arbeitsverzeichnis muss ein absoluter Pfad sein" + }, + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Aktualisieren des Arbeitsverzeichnisses fehlgeschlagen" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index baa70ac6d..d15be0468 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -938,6 +938,15 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Current directory" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Failed to update working directory" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index c1f8c5aff..dc97e7183 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Directorio actual" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "No se pudo actualizar el directorio de trabajo" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index f1bd4f1b7..0e6d14964 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Répertoire actuel" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Échec de la mise à jour du répertoire de travail" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 1177c59fe..49223dad0 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "वर्तमान निर्देशिका" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "कार्यशील निर्देशिका अद्यतन करने में विफल" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index d7b849014..f78e784ee 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Direktori saat ini" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Gagal memperbarui direktori kerja" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index 45b372522..b1f51d2cb 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Cartella corrente" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Impossibile aggiornare la cartella di lavoro" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 9ef43c700..c81af0811 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "現在のディレクトリ" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "作業ディレクトリの更新に失敗しました" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index eb45acbf2..95823b529 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "현재 디렉터리" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "작업 디렉터리를 업데이트하지 못했습니다." }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 92b89c1f4..b70c86795 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Direktori semasa" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Gagal mengemas kini direktori kerja" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index 7f5b9d6c2..6e93dc0ec 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Diretório atual" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Falha ao atualizar o diretório de trabalho" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 203b6d75f..505933e3d 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Текущий каталог" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Не удалось обновить рабочий каталог" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index e87120181..cdef446f8 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Geçerli dizin" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Çalışma dizini güncellenemedi" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 986d8abc0..a447b8fff 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "Thư mục hiện tại" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "Không thể cập nhật thư mục làm việc" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index b8e53b01d..c631c76f0 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "当前目录" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "更新工作目录失败" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index 81d074881..7f3d88ea2 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -917,6 +917,17 @@ "dirSwitcher.currentDirectory": { "defaultMessage": "目前目錄" }, + "dirSwitcher.enterPath": { + "defaultMessage": "Enter path" + }, + "dirSwitcher.enterPathPlaceholder": { + "defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)" + }, + "dirSwitcher.enterPathInvalid": { + "defaultMessage": "Working directory must be an absolute path" + }, + + "dirSwitcher.failedToUpdateWorkingDir": { "defaultMessage": "無法更新工作目錄" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 5835c8e34..b2e63c297 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -988,6 +988,11 @@ let appConfig = { GOOSE_PREDEFINED_MODELS: predefinedModels, GOOSE_PATH_ROOT: sanitizeGoosePathRoot(process.env), GOOSE_WORKING_DIR: '', + // Whether the window is bound to an external backend (fixed at window + // creation via gooseServeLeases) and which URL it is bound to. + GOOSE_EXTERNAL_BACKEND: false, + GOOSE_EXTERNAL_BACKEND_URL: '', + GOOSE_EXTERNAL_BACKEND_SOURCE: '', // Start with the env-var override; the OS region locale is filled in after app.ready // (see updateLocaleFromSystem below) since getSystemLocale() cannot be called earlier. GOOSE_LOCALE: process.env.GOOSE_LOCALE || undefined, @@ -1310,6 +1315,9 @@ const createChat = async ( ...appConfig, GOOSE_LOCALE: getConfiguredGooseLocale(), GOOSE_WORKING_DIR: workingDir, + GOOSE_EXTERNAL_BACKEND: externalBackend !== null, + GOOSE_EXTERNAL_BACKEND_URL: externalBackend?.url ?? '', + GOOSE_EXTERNAL_BACKEND_SOURCE: externalBackend?.source ?? '', REQUEST_DIR: dir, GOOSE_VERSION: version, recipeDeeplink: recipeDeeplink, diff --git a/ui/desktop/src/utils/__tests__/workingDir.test.ts b/ui/desktop/src/utils/__tests__/workingDir.test.ts index ea2d15409..32829c55a 100644 --- a/ui/desktop/src/utils/__tests__/workingDir.test.ts +++ b/ui/desktop/src/utils/__tests__/workingDir.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { resolveWorkingDir } from '../workingDir'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getEffectiveWorkingDir, resolveWorkingDir } from '../workingDir'; describe('resolveWorkingDir', () => { it('uses the configured external backend directory when present', () => { @@ -10,3 +10,87 @@ describe('resolveWorkingDir', () => { expect(resolveWorkingDir(undefined, undefined, 'C:\\Users\\goose')).toBe('C:\\Users\\goose'); }); }); + +describe('getEffectiveWorkingDir', () => { + const getSettingMock = vi.fn(); + const appConfigGetMock = vi.fn(); + + const mockWindow = (externalBackend: boolean, boundUrl: string, source = 'settings') => { + appConfigGetMock.mockImplementation((key: string) => { + if (key === 'GOOSE_EXTERNAL_BACKEND') return externalBackend; + if (key === 'GOOSE_EXTERNAL_BACKEND_URL') return boundUrl; + if (key === 'GOOSE_EXTERNAL_BACKEND_SOURCE') return source; + if (key === 'GOOSE_WORKING_DIR') return '/Users/johannes/home/workspace'; + return undefined; + }); + }; + + beforeEach(() => { + getSettingMock.mockReset(); + appConfigGetMock.mockReset(); + (globalThis as Record).window = { + appConfig: { get: appConfigGetMock }, + electron: { getSetting: getSettingMock }, + } as unknown as typeof globalThis; + }); + + it('prefers the configured remote directory when bound to the matching external backend', async () => { + mockWindow(true, 'http://remote:3000/'); + getSettingMock.mockResolvedValue({ + enabled: true, + url: 'http://remote:3000', + workingDir: ' /home/goose/workspace ', + }); + await expect(getEffectiveWorkingDir()).resolves.toBe('/home/goose/workspace'); + }); + + it('honors the configured remote directory for env-mode backends regardless of enabled/url', async () => { + mockWindow(true, 'http://env-backend:3000', 'env'); + getSettingMock.mockResolvedValue({ + enabled: false, + url: 'http://unrelated:4000', + workingDir: '/home/goose/workspace', + }); + await expect(getEffectiveWorkingDir()).resolves.toBe('/home/goose/workspace'); + }); + + it('falls back to the remembered directory for env-mode backends without a configured dir', async () => { + mockWindow(true, 'http://env-backend:3000', 'env'); + getSettingMock.mockResolvedValue({ enabled: false, workingDir: ' ' }); + await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace'); + }); + + it('ignores the remote directory when the window is bound to the local backend', async () => { + mockWindow(false, ''); + getSettingMock.mockResolvedValue({ enabled: true, workingDir: '/home/goose/workspace' }); + await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace'); + }); + + it('falls back to the remembered directory when the bound backend no longer matches settings', async () => { + mockWindow(true, 'http://server-a:3000'); + getSettingMock.mockResolvedValue({ + enabled: true, + url: 'http://server-b:3000', + workingDir: '/home/goose/workspace', + }); + await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace'); + }); + + it('falls back to the remembered directory when the external backend is disabled', async () => { + mockWindow(true, 'http://remote:3000'); + getSettingMock.mockResolvedValue({ enabled: false, workingDir: '/home/goose/workspace' }); + await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace'); + }); + + it('falls back to the remembered directory when the remote directory is blank', async () => { + mockWindow(true, 'http://remote:3000'); + getSettingMock.mockResolvedValue({ enabled: true, workingDir: ' ' }); + await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace'); + }); + + it('falls back to the remembered directory when the setting cannot be read', async () => { + mockWindow(true, 'http://remote:3000'); + getSettingMock.mockRejectedValue(new Error('settings unavailable')); + await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace'); + }); +}); diff --git a/ui/desktop/src/utils/workingDir.ts b/ui/desktop/src/utils/workingDir.ts index 4a0c9afcc..a0465208f 100644 --- a/ui/desktop/src/utils/workingDir.ts +++ b/ui/desktop/src/utils/workingDir.ts @@ -3,6 +3,55 @@ export const getInitialWorkingDir = (): string => { return (window.appConfig?.get('GOOSE_WORKING_DIR') as string) ?? ''; }; +/** + * Resolve the working directory for a new chat in the current window. + * + * GOOSE_WORKING_DIR is fixed when the window is created, so it goes stale when + * the user switches to an external backend (or changes the configured remote + * directory) afterwards. The configured remote directory is only applied when + * the window is actually bound to an external backend (fixed at window creation + * via the gooseServeLeases) and that backend still matches the current + * settings; otherwise the remote path would be sent to the local (or a + * different remote) server, where it fails the cwd existence validation. + * Editing the remote working directory in settings still takes effect for new + * chats in the same window. Env-mode backends (GOOSE_EXTERNAL_BACKEND) always + * use the configured directory (matching getActiveExternalBackend), while + * settings-mode backends require the window-bound backend to still match. + */ +export const getEffectiveWorkingDir = async (): Promise => { + const initial = getInitialWorkingDir(); + const boundUrl = window.appConfig?.get('GOOSE_EXTERNAL_BACKEND_URL') as string | undefined; + const source = window.appConfig?.get('GOOSE_EXTERNAL_BACKEND_SOURCE') as string | undefined; + if (window.appConfig?.get('GOOSE_EXTERNAL_BACKEND') !== true || !boundUrl) { + return initial; + } + try { + const external = await window.electron.getSetting('externalGoosed'); + const remote = external?.workingDir?.trim(); + if (!remote) { + return initial; + } + // Env-mode backends use settings.externalGoosed.workingDir regardless of the + // enabled flag or URL (see getActiveExternalBackend); settings-mode requires + // the backend to still match the window-bound URL. + if (source === 'env') { + return remote; + } + if ( + external?.enabled && + external?.url && + normalizeUrl(boundUrl) === normalizeUrl(external.url) + ) { + return remote; + } + } catch { + // Settings unavailable; fall back to the remembered directory. + } + return initial; +}; + +const normalizeUrl = (url: string): string => url.trim().replace(/\/+$/, ''); + export const resolveWorkingDir = ( externalWorkingDir: string | undefined, requestedWorkingDir: string | undefined,