From de7c93e4aaba412b518da2b615f22a49e9b1c99e Mon Sep 17 00:00:00 2001 From: cat0825 <1759138827@qq.com> Date: Fri, 14 Aug 2026 15:02:42 +0800 Subject: [PATCH 1/2] feat(desktop): add a default working directory Let local sessions without a Project, including Bot conversations, use a persisted directory selected in Settings. Fixes #2322 Generated-by: Codex --- .../client-settings-ipc-main.test.ts | 22 ++ ...settings-default-working-directory.test.ts | 284 ++++++++++++++++++ .../__tests__/project-root-controller.test.ts | 115 ++++++- .../src/main/client-settings-ipc-main.ts | 9 + apps/desktop/src/main/project-picker-copy.ts | 4 + .../src/main/project-root-controller.ts | 48 ++- apps/desktop/src/main/runtime-host-boot.ts | 18 +- apps/desktop/src/preload/bridge-contract.d.ts | 4 + apps/desktop/src/preload/preload.ts | 3 + .../locales/settings-preferences-copy.ts | 9 +- .../default-working-directory-row.tsx | 160 ++++++++++ .../settings/general-settings-page.tsx | 24 ++ .../src/renderer/styles/settings/rows.css | 8 + .../settings/settings-pages.stories.tsx | 28 +- packages/core/src/__tests__/settings.test.ts | 38 +++ packages/core/src/settings.ts | 10 +- 16 files changed, 773 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/general-settings-default-working-directory.test.ts create mode 100644 apps/desktop/src/renderer/settings/default-working-directory-row.tsx diff --git a/apps/desktop/src/main/__tests__/client-settings-ipc-main.test.ts b/apps/desktop/src/main/__tests__/client-settings-ipc-main.test.ts index 3b6f9e4438..800f1166fb 100644 --- a/apps/desktop/src/main/__tests__/client-settings-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/client-settings-ipc-main.test.ts @@ -45,6 +45,7 @@ test("client settings updates filter Host policy and return newly submitted secr return settings; }, } as never, + chooseDefaultWorkingDirectory: async () => undefined, apply: async () => { applied += 1; }, @@ -68,3 +69,24 @@ test("client settings updates filter Host policy and return newly submitted secr assert.equal(settings.chatDefaults.permissionMode, "ask"); assert.equal(applied, 1); }); + +// The default working directory is client-owned (`projects` is a client-tier +// section), so its folder picker is registered on the client channel and stays +// reachable regardless of which Runtime Host is selected. +test("the default working directory picker answers on the client channel", async () => { + const handlers = new Map unknown>(); + registerClientSettingsIpc({ + ipcMain: { + handle(channel, listener) { + handlers.set(channel, listener as (...args: unknown[]) => unknown); + }, + }, + settingsStore: { get: async () => createDefaultSettings() } as never, + apply: async () => {}, + chooseDefaultWorkingDirectory: async () => "/Users/example/agent", + }); + + const choose = handlers.get("settings:client:chooseDefaultWorkingDirectory"); + assert.ok(choose); + assert.equal(await choose({}), "/Users/example/agent"); +}); diff --git a/apps/desktop/src/main/__tests__/general-settings-default-working-directory.test.ts b/apps/desktop/src/main/__tests__/general-settings-default-working-directory.test.ts new file mode 100644 index 0000000000..151beab7f1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/general-settings-default-working-directory.test.ts @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The default working directory is a client-owned, local-only preference. A + * remote Runtime Host advertises `setLocalDefault: false` and its + * ProjectRootController never receives the callback, so offering the control + * there would let a user save a path the target is incapable of using. These + * tests pin the capability gate, and pin the save to the client-owned + * (per-machine) `projects` section rather than anything Host-shared. + */ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; +import type { UpdateAppSettingsInput } from '@maka/core/settings'; +import type { + DesktopProjectCapabilities, + DesktopRuntimeHostRef, +} from '../../preload/bridge-contract.js'; +import { + DefaultWorkingDirectoryRow, + useLocalDefaultCapability, +} from '../../renderer/settings/default-working-directory-row.js'; + +const TEST_RUNTIME_HOST: DesktopRuntimeHostRef = { + profileId: 'test-profile', + hostId: 'test-host', +}; + +const CONFIGURED_DIRECTORY = '/Users/example/agent'; +const CHOSEN_DIRECTORY = '/Users/example/picked'; + +const LOCAL_CAPABILITIES: DesktopProjectCapabilities = { + chooseClientDirectory: true, + chooseHostDirectory: false, + selectNoProject: true, + setLocalDefault: true, + viewClientPath: true, +}; + +const REMOTE_CAPABILITIES: DesktopProjectCapabilities = { + chooseClientDirectory: false, + chooseHostDirectory: true, + selectNoProject: false, + setLocalDefault: false, + viewClientPath: false, +}; + +interface RowHarness { + container: HTMLElement; + root: Root; + patches: UpdateAppSettingsInput[]; + pickerCalls(): number; + gateStates: boolean[]; +} + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +afterEach(() => { + Object.assign(globalThis, originalGlobals); +}); + +test('a local Runtime Host may set the directory, and its current value is shown', async () => { + const harness = await renderRow({ capabilities: LOCAL_CAPABILITIES }); + + assert.deepEqual(harness.gateStates.at(-1), true); + assert.match(harness.container.textContent ?? '', /Default working directory/); + assert.match(harness.container.textContent ?? '', new RegExp(CONFIGURED_DIRECTORY)); + assert.ok(buttonWithLabel(harness.container, 'Choose folder')); + + await unmount(harness); +}); + +test('a remote Runtime Host cannot set a local-only default', async () => { + const harness = await renderRow({ capabilities: REMOTE_CAPABILITIES }); + + assert.deepEqual(harness.gateStates.at(-1), false); + assert.doesNotMatch(harness.container.textContent ?? '', /Default working directory/); + assert.equal(buttonWithLabel(harness.container, 'Choose folder'), undefined); + + await unmount(harness); +}); + +test('a failed capability read leaves the control hidden rather than guessing', async () => { + const harness = await renderRow({ + capabilities: new Error('project snapshot unavailable'), + }); + + assert.deepEqual(harness.gateStates, [false]); + assert.equal(buttonWithLabel(harness.container, 'Choose folder'), undefined); + + await unmount(harness); +}); + +test('an unverified target cannot set the directory', async () => { + const harness = await renderRow({ + capabilities: LOCAL_CAPABILITIES, + targetVerified: false, + }); + + assert.deepEqual(harness.gateStates, [false]); + assert.equal(buttonWithLabel(harness.container, 'Choose folder'), undefined); + + await unmount(harness); +}); + +test('choosing a folder patches the client-owned Project preferences', async () => { + const harness = await renderRow({ capabilities: LOCAL_CAPABILITIES }); + + await clickButton(harness, 'Choose folder'); + + assert.deepEqual(harness.patches, [ + { projects: { defaultWorkingDirectory: CHOSEN_DIRECTORY } }, + ]); + assert.equal(harness.pickerCalls(), 1); + + await unmount(harness); +}); + +test('a cancelled picker is not a request to clear the directory', async () => { + const harness = await renderRow({ + capabilities: LOCAL_CAPABILITIES, + chosenDirectory: undefined, + }); + + await clickButton(harness, 'Choose folder'); + + assert.equal(harness.pickerCalls(), 1); + assert.deepEqual(harness.patches, []); + + await unmount(harness); +}); + +test('clearing sends an undefined directory and never opens a picker', async () => { + const harness = await renderRow({ capabilities: LOCAL_CAPABILITIES }); + + await clickButton(harness, 'Clear'); + + assert.deepEqual(harness.patches, [{ projects: { defaultWorkingDirectory: undefined } }]); + assert.equal(harness.pickerCalls(), 0); + + await unmount(harness); +}); + +test('there is nothing to clear when no directory is configured', async () => { + const harness = await renderRow({ + capabilities: LOCAL_CAPABILITIES, + defaultWorkingDirectory: undefined, + }); + + assert.ok(buttonWithLabel(harness.container, 'Choose folder')); + assert.equal(buttonWithLabel(harness.container, 'Clear'), undefined); + assert.match(harness.container.textContent ?? '', /Not set/); + + await unmount(harness); +}); + +async function renderRow(options: { + capabilities: DesktopProjectCapabilities | Error; + defaultWorkingDirectory?: string; + chosenDirectory?: string; + targetVerified?: boolean; +}): Promise { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + requestAnimationFrame: (callback: () => void) => setImmediate(callback), + cancelAnimationFrame: (handle: NodeJS.Immediate) => clearImmediate(handle), + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const patches: UpdateAppSettingsInput[] = []; + let pickerCalls = 0; + const bridge = { + projects: { + getSnapshot: async () => { + if (options.capabilities instanceof Error) throw options.capabilities; + return { projects: [], capabilities: options.capabilities }; + }, + }, + settings: { + chooseDefaultWorkingDirectory: async () => { + pickerCalls += 1; + return 'chosenDirectory' in options ? options.chosenDirectory : CHOSEN_DIRECTORY; + }, + }, + }; + // The row only uses these two bridge namespaces; a full MakaBridge fixture + // would couple this test to every unrelated channel. + Object.assign(window, { maka: bridge }); + Object.assign(globalThis, { maka: bridge }); + + const gateStates: boolean[] = []; + const configured = + 'defaultWorkingDirectory' in options + ? options.defaultWorkingDirectory + : CONFIGURED_DIRECTORY; + + function Harness() { + const canSetLocalDefault = useLocalDefaultCapability( + TEST_RUNTIME_HOST, + options.targetVerified ?? true, + ); + gateStates.push(canSetLocalDefault); + if (!canSetLocalDefault) return null; + return createElement(DefaultWorkingDirectoryRow, { + defaultWorkingDirectory: configured, + onUpdate: async (patch: UpdateAppSettingsInput) => { + patches.push(patch); + return { settings: {} as never }; + }, + }); + } + + const container = document.querySelector('#root'); + assert.ok(container instanceof window.HTMLElement); + const root = createRoot(container); + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(ToastProvider, { + children: createElement(Harness), + }), + }), + ); + }); + // The capability read resolves a microtask after mount. + await act(async () => undefined); + return { container, root, patches, pickerCalls: () => pickerCalls, gateStates }; +} + +async function clickButton(harness: RowHarness, label: string): Promise { + const button = buttonWithLabel(harness.container, label); + assert.ok(button, `expected a "${label}" button`); + await act(async () => { + button.click(); + }); + // The click awaits the picker and then the save. + await act(async () => undefined); +} + +async function unmount(harness: RowHarness): Promise { + await act(async () => { + harness.root.unmount(); + }); +} + +function buttonWithLabel(container: HTMLElement, label: string): HTMLButtonElement | undefined { + const buttons = container.querySelectorAll('button'); + return [...buttons].find( + (button) => + button.getAttribute('aria-label') === label || button.textContent?.trim() === label, + ) as HTMLButtonElement | undefined; +} diff --git a/apps/desktop/src/main/__tests__/project-root-controller.test.ts b/apps/desktop/src/main/__tests__/project-root-controller.test.ts index 2ab8b25426..2d47bfee59 100644 --- a/apps/desktop/src/main/__tests__/project-root-controller.test.ts +++ b/apps/desktop/src/main/__tests__/project-root-controller.test.ts @@ -83,10 +83,123 @@ test('does not reuse a preference from another Runtime Host root', async () => { } }); -function controller(base: string, fallback: string, rootId: string) { +test('uses the configured working directory dynamically when no Project is selected', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-default-working-directory-')); + const fallback = join(base, 'fallback'); + const firstDefault = join(base, 'agent-a'); + const secondDefault = join(base, 'agent-b'); + await Promise.all([mkdir(fallback), mkdir(firstDefault), mkdir(secondDefault)]); + let configured = firstDefault; + const current = controller(base, fallback, 'root-a', async () => configured); + try { + assert.equal(await current.current(), firstDefault); + configured = secondDefault; + assert.equal(await current.current(), secondDefault); + + await current.setSelection('project-a', fallback); + configured = firstDefault; + assert.equal(await current.current(), fallback); + + await current.setSelection(null, fallback); + assert.equal(await current.current(), firstDefault); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('falls back when the configured working directory is unavailable', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-default-working-directory-missing-')); + const fallback = join(base, 'fallback'); + await mkdir(fallback); + try { + assert.equal( + await controller(base, fallback, 'root-a', async () => join(base, 'missing')).current(), + fallback, + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +// P2a: the optional default directory must never be a precondition of Project +// recovery. A rejecting callback is an unset preference at the fallback +// boundary, so an existing Project ID still comes back and the no-Project case +// degrades to the fallback roots instead of rejecting. +test('a rejecting default-directory callback does not block Project recovery', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-default-working-directory-rejects-')); + const fallback = join(base, 'fallback'); + await mkdir(fallback); + await writeFile( + join(base, 'project-preferences.json'), + JSON.stringify({ version: 1, selections: { 'root-a': 'project-a' } }), + ); + const rejecting = async (): Promise => { + throw new Error('settings.json is malformed'); + }; + try { + assert.deepEqual(await controller(base, fallback, 'root-a', rejecting).currentSelection(), { + projectId: 'project-a', + path: fallback, + }); + + assert.deepEqual(await controller(base, fallback, 'root-b', rejecting).currentSelection(), { + projectId: undefined, + path: fallback, + }); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +// P1: `setSelection()` writes the selection synchronously, so it can land while +// either await inside `currentSelection()` is still pending. The continuation +// must not commit its stale unassociated result over that newer Project. +test('a selection made during resolution is not overwritten by the pending default', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-default-working-directory-race-')); + const fallback = join(base, 'fallback'); + const configured = join(base, 'agent'); + const projectPath = join(base, 'project'); + await Promise.all([mkdir(fallback), mkdir(configured), mkdir(projectPath)]); + try { + let releaseDefault = (): void => {}; + const gate = new Promise((resolve) => { + releaseDefault = resolve; + }); + const deferred = controller(base, fallback, 'root-a', async () => { + await gate; + return configured; + }); + const pending = deferred.currentSelection(); + await deferred.setSelection('project-a', projectPath); + releaseDefault(); + + assert.deepEqual(await pending, { projectId: 'project-a', path: projectPath }); + assert.deepEqual(await deferred.currentSelection(), { + projectId: 'project-a', + path: projectPath, + }); + + // The same invariant across the initial-preference await, which resolves + // before the default directory is ever consulted. + const early = controller(base, fallback, 'root-b', async () => configured); + const earlyPending = early.currentSelection(); + await early.setSelection('project-b', projectPath); + assert.deepEqual(await earlyPending, { projectId: 'project-b', path: projectPath }); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +function controller( + base: string, + fallback: string, + rootId: string, + defaultWorkingDirectory?: () => Promise, +) { return createProjectRootController({ rootId, preferenceFile: join(base, 'project-preferences.json'), fallbackRoots: () => [fallback], + defaultWorkingDirectory, }); } diff --git a/apps/desktop/src/main/client-settings-ipc-main.ts b/apps/desktop/src/main/client-settings-ipc-main.ts index a5e0e21e29..fe2f686dd1 100644 --- a/apps/desktop/src/main/client-settings-ipc-main.ts +++ b/apps/desktop/src/main/client-settings-ipc-main.ts @@ -37,10 +37,19 @@ export function registerClientSettingsIpc(deps: { readonly ipcMain: Pick; readonly settingsStore: SettingsStore; readonly apply: (settings: AppSettings) => Promise; + readonly chooseDefaultWorkingDirectory: () => Promise; }): void { deps.ipcMain.handle("settings:client:get", async () => maskAppSettings(await deps.settingsStore.get()), ); + // The default working directory lives in the client tier because + // `projects` is client-owned: the path names a folder on THIS machine, so a + // Host-shared value would be wrong for every other client of that Host. + // Registering the picker here also keeps it off the per-target settings + // channel, which only exists while a Runtime Host is selected. + deps.ipcMain.handle("settings:client:chooseDefaultWorkingDirectory", () => + deps.chooseDefaultWorkingDirectory(), + ); deps.ipcMain.handle( "settings:client:update", async ( diff --git a/apps/desktop/src/main/project-picker-copy.ts b/apps/desktop/src/main/project-picker-copy.ts index 80e2a347be..613df5e0a4 100644 --- a/apps/desktop/src/main/project-picker-copy.ts +++ b/apps/desktop/src/main/project-picker-copy.ts @@ -22,3 +22,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; export function projectPickerTitle(locale: UiLocale): string { return locale === 'zh' ? '添加项目' : 'Add project'; } + +export function defaultWorkingDirectoryPickerTitle(locale: UiLocale): string { + return locale === 'zh' ? '选择默认工作目录' : 'Choose default working directory'; +} diff --git a/apps/desktop/src/main/project-root-controller.ts b/apps/desktop/src/main/project-root-controller.ts index 8f3eea0a99..ba51b96806 100644 --- a/apps/desktop/src/main/project-root-controller.ts +++ b/apps/desktop/src/main/project-root-controller.ts @@ -19,6 +19,7 @@ import { randomUUID } from 'node:crypto'; import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { resolveProjectRoot } from '@maka/runtime/system-prompt/project-context'; export interface CurrentProjectSelection { @@ -42,6 +43,7 @@ export interface ProjectRootControllerDeps { readonly rootId: string; readonly preferenceFile: string; readonly fallbackRoots: () => string[]; + readonly defaultWorkingDirectory?: () => Promise; } interface ProjectPreferenceFile { @@ -55,11 +57,24 @@ export function createProjectRootController( deps: ProjectRootControllerDeps, ): ProjectRootController { let selectedProject: CurrentProjectSelection | null = null; + // Bumped by every explicit selection so a resolution that started earlier + // cannot commit its result over a newer one. + let selectionGeneration = 0; const initialSelection = loadInitialSelection(deps); async function currentSelection(): Promise { - if (selectedProject) return selectedProject; - return (selectedProject = await initialSelection); + const generation = selectionGeneration; + const base = selectedProject ?? (await initialSelection); + // `setSelection()` writes `selectedProject` synchronously, so an explicit + // Project can be installed while either await here is pending. Its value + // is newer than anything this call computed: committing the stale + // unassociated selection would leave the active session on the default + // directory after the caller was told the Project selection succeeded. + if (selectionGeneration !== generation && selectedProject) return selectedProject; + if (typeof base.projectId === 'string') return (selectedProject = base); + const path = await resolveUnassociatedRoot(deps); + if (selectionGeneration !== generation && selectedProject) return selectedProject; + return (selectedProject = { ...base, path }); } async function current(): Promise { @@ -82,6 +97,7 @@ export function createProjectRootController( } function setSelection(projectId: string | null, projectPath: string): Promise { + selectionGeneration += 1; selectedProject = { projectId, path: projectPath }; return persistSelection(deps, projectId); } @@ -89,12 +105,36 @@ export function createProjectRootController( return { current, currentSelection, resolveExplicit, setSelection }; } +/** + * Project identity is authoritative whenever it exists, so it is recovered + * first and on its own. The optional default working directory is irrelevant + * to a selected Project and is therefore not consulted here at all: a + * malformed settings file or transient I/O in that callback must never be able + * to reject this promise and take `current()` and Bot workspace resolution + * down with it. The no-Project case reaches the directory lazily through + * `resolveUnassociatedRoot`, which stays the single decision point. + */ async function loadInitialSelection( deps: ProjectRootControllerDeps, ): Promise { - const fallbackPath = await resolveProjectRoot(deps.fallbackRoots()); const preference = await readPreference(deps.preferenceFile, deps.rootId); - return { projectId: preference, path: fallbackPath }; + return { projectId: preference, path: await resolveProjectRoot(deps.fallbackRoots()) }; +} + +/** + * The one place that decides the working directory for a conversation with no + * Project: configured default, then the established fallback roots. A + * rejecting callback is treated exactly like an unset preference — this is a + * fallback boundary, so it fails open rather than propagating. + */ +async function resolveUnassociatedRoot(deps: ProjectRootControllerDeps): Promise { + const configured = await deps.defaultWorkingDirectory?.().catch(() => undefined); + if (configured) { + const path = resolve(configured); + const info = await stat(path).catch(() => undefined); + if (info?.isDirectory()) return path; + } + return resolveProjectRoot(deps.fallbackRoots()); } async function persistSelection( diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 4dd1639eab..6cc0d6493f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -136,7 +136,10 @@ import { import { resolveProjectContextRoot } from "./project-context-root.js"; import { resolveDefaultPermissionMode } from "./permission-mode-default.js"; import { createProjectManagementService } from "./project-management-service.js"; -import { projectPickerTitle } from "./project-picker-copy.js"; +import { + defaultWorkingDirectoryPickerTitle, + projectPickerTitle, +} from "./project-picker-copy.js"; import type { ProjectManagementService } from "./project-management-service.js"; import { createProjectRootController, @@ -1127,6 +1130,12 @@ function registerHostClientIpc( rootId: target.rootId, preferenceFile: join(workspaceRoot, "project-preferences.json"), fallbackRoots: () => [process.cwd(), app.getAppPath()], + ...(target.kind === "local" + ? { + defaultWorkingDirectory: async () => + (await settingsStore.get()).projects.defaultWorkingDirectory, + } + : {}), }); const targetProjectCatalog = createRuntimeHostProjectCatalog(() => ({ client, @@ -1472,6 +1481,13 @@ function registerPersistentClientIpc(): void { registerClientSettingsIpc({ ipcMain, settingsStore, + chooseDefaultWorkingDirectory: async () => { + const result = await mainWindowController.showOpenDialog({ + title: defaultWorkingDirectoryPickerTitle(await desktopLocale.resolve()), + properties: ["openDirectory", "createDirectory"], + }); + return result.canceled ? undefined : result.filePaths[0]; + }, apply: async (settings) => { await clientSettingsEffects.apply(settings, true); }, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 94dcc8db3b..3d8ffd9e51 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1262,6 +1262,10 @@ export interface MakaBridge { settings: { getClient(): Promise; get(host?: DesktopRuntimeHostRef): Promise; + /** Opens the local folder picker for the client-owned default working + * directory. Client-tier because `projects` is client-owned, so the + * directory is per-machine rather than shared by every client of a Host. */ + chooseDefaultWorkingDirectory(): Promise; updateClient(patch: UpdateAppSettingsInput): Promise; update(patch: UpdateAppSettingsInput, host?: DesktopRuntimeHostRef): Promise; subscribeClientChanged(handler: () => void): () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0ad27fe096..9c12b14baa 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2778,6 +2778,9 @@ const makaBridge = { getClient(): Promise { return ipcRenderer.invoke('settings:client:get'); }, + chooseDefaultWorkingDirectory(): Promise { + return ipcRenderer.invoke('settings:client:chooseDefaultWorkingDirectory'); + }, get(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'settings:get'); }, diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 770e7e9953..28b41b5edd 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -195,6 +195,11 @@ export type SettingsPreferencesCopy = { shellSaved: string; saveShellFailed: string; shellExecutableRejected: string; + defaultWorkingDirectory: string; + defaultWorkingDirectoryHelp: string; + chooseDefaultWorkingDirectory: string; + clearDefaultWorkingDirectory: string; + saveDefaultWorkingDirectoryFailed: string; proxy: string; proxyHelp: string; enableProxy: string; @@ -332,7 +337,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { }, general: { incognito: '隐身模式', incognitoHelp: '开启后暂停本地记忆读写、联网搜索和定时任务触发。', enableIncognito: '启用隐身模式', incognitoFailed: '隐身模式切换失败', notifications: '完成时发送系统通知', notificationsHelp: '窗口不在前台时,在回答完成或出错后发送桌面通知。', notificationsFailed: '通知设置切换失败', workspaceInstructions: '遵循项目指令', workspaceInstructionsHelp: '自动读取每个项目中已有的 AGENTS.md、CLAUDE.md 或 GEMINI.md;文件仍由各自项目管理。', workspaceInstructionsFailed: '项目指令设置切换失败', workHub: '启用 WorkHub', workHubHelp: 'WorkHub 目前仍不可用。此开关仅供开发测试,开启后也不能保证正常使用。', workHubFailed: 'WorkHub 设置切换失败', updateFailed: '设置未生效,请稍后重试。', - defaultModel: '默认模型', defaultModelHelp: '新任务默认使用的模型。', notSet: '未设置', saveDefaultModelFailed: '保存默认模型失败', defaultPermission: '默认权限模式', defaultPermissionHelp: '新任务默认使用的权限模式;可在任务内随时切换。', saveDefaultPermissionFailed: '保存默认权限模式失败', defaultThinking: '默认思考级别', defaultThinkingHelp: '新任务的思考级别;当前模型不支持所选级别时用模型默认。', followModelDefault: '跟随模型默认', saveDefaultThinkingFailed: '保存默认思考级别失败', + defaultModel: '默认模型', defaultModelHelp: '新任务默认使用的模型。', notSet: '未设置', saveDefaultModelFailed: '保存默认模型失败', defaultPermission: '默认权限模式', defaultPermissionHelp: '新任务默认使用的权限模式;可在任务内随时切换。', saveDefaultPermissionFailed: '保存默认权限模式失败', defaultThinking: '默认思考级别', defaultThinkingHelp: '新任务的思考级别;当前模型不支持所选级别时用模型默认。', followModelDefault: '跟随模型默认', saveDefaultThinkingFailed: '保存默认思考级别失败', defaultWorkingDirectory: '默认工作目录', defaultWorkingDirectoryHelp: '没有指定项目或文件夹的新任务与 Bot 对话使用此目录。仅对本地 Runtime Host 生效。', chooseDefaultWorkingDirectory: '选择文件夹', clearDefaultWorkingDirectory: '清除', saveDefaultWorkingDirectoryFailed: '保存默认工作目录失败', shellPreference: 'Bash 工具 shell', shellPreferenceHelp: '自动模式保持 Windows 的 PowerShell 优先规则;Git Bash 是仅对当前 Runtime Host 生效的显式覆盖。', shellAuto: '自动(推荐)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash 可执行文件', shellExecutableHelp: '填写 Runtime Host 所在 Windows 机器上 bash.exe 的绝对路径。也支持该机器上的旧版 System32 WSL Bash;保存时会验证 GNU Bash。', saveShell: '保存 shell 设置', savingShell: '正在保存…', shellSaved: '已保存', saveShellFailed: '保存 shell 设置失败', shellExecutableRejected: '当前 Runtime Host 无法把该路径作为 GNU Bash 运行。请检查 Host 是否为 Windows、路径是否存在,并确认文件名为 bash.exe。', proxy: '代理服务器', proxyHelp: '为 AI 模型请求配置网络代理', enableProxy: '启用代理服务器', saveNetworkFailed: '保存网络设置失败', proxyProtocol: '代理协议', serverAddress: '服务器地址', port: '端口', proxyAuth: '代理认证', proxyAuthHelp: '需要用户名和密码时开启。', enableProxyAuth: '启用代理认证', username: '用户名', password: '密码', bypassList: '代理白名单', bypassHelp: '这些域名将绕过代理直连,多个用逗号分隔。', autoBypass: (count) => `已自动添加 ${count} 个域名。代理仅作用于 AI 模型请求。`, testing: '测试中…', testCurrent: '测试当前配置', proxyReachable: '代理可达', proxyTestFailed: '代理测试失败', proxyTestError: '代理测试出错', }, @@ -386,7 +391,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { removeErrors: { invalid_id: 'The pet ID is invalid.', remove_failed: 'The local pet pack could not be removed.' }, }, general: { - incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', workspaceInstructionsHelp: 'Automatically read existing AGENTS.md, CLAUDE.md, or GEMINI.md files in each project. Manage the files in their respective projects.', workspaceInstructionsFailed: 'Could not change project instruction settings', workHub: 'Enable WorkHub', workHubHelp: 'WorkHub is not available yet. This toggle is for development testing and does not enable a usable feature.', workHubFailed: 'Could not change WorkHub setting', updateFailed: 'The setting was not applied. Try again later.', defaultModel: 'Default model', defaultModelHelp: 'Model used by new tasks.', notSet: 'Not set', saveDefaultModelFailed: 'Could not save the default model', defaultPermission: 'Default permission mode', defaultPermissionHelp: 'Initial permission mode for new tasks; it can be changed at any time.', saveDefaultPermissionFailed: 'Could not save the default permission mode', defaultThinking: 'Default thinking level', defaultThinkingHelp: 'Thinking level for new tasks; models that do not offer the chosen level use their own default.', followModelDefault: 'Follow model default', saveDefaultThinkingFailed: 'Could not save the default thinking level', proxy: 'Proxy server', proxyHelp: 'Configure a network proxy for AI model requests', enableProxy: 'Enable proxy server', saveNetworkFailed: 'Could not save network settings', proxyProtocol: 'Proxy protocol', serverAddress: 'Server address', port: 'Port', proxyAuth: 'Proxy authentication', proxyAuthHelp: 'Enable this when a username and password are required.', enableProxyAuth: 'Enable proxy authentication', username: 'Username', password: 'Password', bypassList: 'Proxy bypass list', bypassHelp: 'These domains connect directly. Separate multiple domains with commas.', autoBypass: (count) => `${count} ${count === 1 ? 'domain was' : 'domains were'} added automatically. The proxy applies to AI model requests only.`, testing: 'Testing…', testCurrent: 'Test current configuration', proxyReachable: 'Proxy is reachable', proxyTestFailed: 'Proxy test failed', proxyTestError: 'Could not test proxy', + incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', workspaceInstructionsHelp: 'Automatically read existing AGENTS.md, CLAUDE.md, or GEMINI.md files in each project. Manage the files in their respective projects.', workspaceInstructionsFailed: 'Could not change project instruction settings', workHub: 'Enable WorkHub', workHubHelp: 'WorkHub is not available yet. This toggle is for development testing and does not enable a usable feature.', workHubFailed: 'Could not change WorkHub setting', updateFailed: 'The setting was not applied. Try again later.', defaultModel: 'Default model', defaultModelHelp: 'Model used by new tasks.', notSet: 'Not set', saveDefaultModelFailed: 'Could not save the default model', defaultPermission: 'Default permission mode', defaultPermissionHelp: 'Initial permission mode for new tasks; it can be changed at any time.', saveDefaultPermissionFailed: 'Could not save the default permission mode', defaultThinking: 'Default thinking level', defaultThinkingHelp: 'Thinking level for new tasks; models that do not offer the chosen level use their own default.', followModelDefault: 'Follow model default', saveDefaultThinkingFailed: 'Could not save the default thinking level', defaultWorkingDirectory: 'Default working directory', defaultWorkingDirectoryHelp: 'New tasks and Bot conversations without a project or folder use this directory. It applies to a local Runtime Host only.', chooseDefaultWorkingDirectory: 'Choose folder', clearDefaultWorkingDirectory: 'Clear', saveDefaultWorkingDirectoryFailed: 'Could not save the default working directory', proxy: 'Proxy server', proxyHelp: 'Configure a network proxy for AI model requests', enableProxy: 'Enable proxy server', saveNetworkFailed: 'Could not save network settings', proxyProtocol: 'Proxy protocol', serverAddress: 'Server address', port: 'Port', proxyAuth: 'Proxy authentication', proxyAuthHelp: 'Enable this when a username and password are required.', enableProxyAuth: 'Enable proxy authentication', username: 'Username', password: 'Password', bypassList: 'Proxy bypass list', bypassHelp: 'These domains connect directly. Separate multiple domains with commas.', autoBypass: (count) => `${count} ${count === 1 ? 'domain was' : 'domains were'} added automatically. The proxy applies to AI model requests only.`, testing: 'Testing…', testCurrent: 'Test current configuration', proxyReachable: 'Proxy is reachable', proxyTestFailed: 'Proxy test failed', proxyTestError: 'Could not test proxy', shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', saveShell: 'Save shell setting', savingShell: 'Saving…', shellSaved: 'Saved', saveShellFailed: 'Could not save shell setting', shellExecutableRejected: 'The current Runtime Host could not run that path as GNU Bash. Check that the Host runs Windows, the path exists, and the file is named bash.exe.', }, about: { diff --git a/apps/desktop/src/renderer/settings/default-working-directory-row.tsx b/apps/desktop/src/renderer/settings/default-working-directory-row.tsx new file mode 100644 index 0000000000..e117fb37d3 --- /dev/null +++ b/apps/desktop/src/renderer/settings/default-working-directory-row.tsx @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import type { UpdateAppSettingsResult } from '@maka/core/settings'; +import { Button, useMountedRef, useToast, useUiLocale } from '@maka/ui'; +import { FolderOpen, ICON_SIZE, X } from '@maka/ui/icons'; +import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js'; +import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; +import { settingsActionErrorMessage } from './settings-error-copy.js'; +import { SettingsActions, SettingsRow } from './settings-section.js'; +import { useKeyedActionGuard } from './use-action-guard.js'; + +/** + * Whether the selected Runtime Host can own a local default at all. + * + * The Projects page already reads `setLocalDefault` off the project snapshot to + * decide whether its default-project control means anything on this target; the + * default working directory has exactly the same boundary, so it reuses that + * capability rather than adding a target-specific settings path. Remote targets + * report `false` and never receive `defaultWorkingDirectory` in their + * ProjectRootController, so the control stays hidden there. + */ +export function useLocalDefaultCapability( + host: DesktopRuntimeHostRef | undefined, + targetVerified: boolean, +): boolean { + const mountedRef = useMountedRef(); + const [canSetLocalDefault, setCanSetLocalDefault] = useState(false); + useEffect(() => { + if (!host || !targetVerified) { + setCanSetLocalDefault(false); + return; + } + let cancelled = false; + void window.maka.projects.getSnapshot(undefined, host).then( + (snapshot) => { + if (!cancelled && mountedRef.current) { + setCanSetLocalDefault(snapshot.capabilities.setLocalDefault); + } + }, + // A failed capability read is not a reason to offer a control whose + // target may ignore it; stay hidden rather than guess. + () => { + if (!cancelled && mountedRef.current) setCanSetLocalDefault(false); + }, + ); + return () => { + cancelled = true; + }; + }, [host, mountedRef, targetVerified]); + return canSetLocalDefault; +} + +/** + * Settings · 常规 · 默认工作目录 — the folder new tasks and Bot conversations + * open in when they have no Project. + * + * The row only reports and edits the preference; which directory a session + * actually gets is decided in one place in the main process + * (`resolveUnassociatedRoot`: selected Project → configured default → + * fallback roots). Nothing here re-derives a path. + * + * The value lives in the client-owned `projects` section, so it is per-machine + * and saved through the client settings tier, not shared with every other + * client of a Runtime Host — a working directory only exists on one filesystem. + */ +export function DefaultWorkingDirectoryRow(props: { + defaultWorkingDirectory?: string; + onUpdate( + patch: Parameters[0], + ): Promise; +}) { + const locale = useUiLocale(); + const copy = getSettingsPreferencesCopy(locale).general; + const toast = useToast(); + const mountedRef = useMountedRef(); + // Same re-entrancy reasoning as the other rows in this card: a disabled + // trigger cannot fully prevent overlapping saves, and overlapping + // settings.update calls have no ordering guarantee. + const persistGuard = useKeyedActionGuard<'working-directory'>(); + const [saving, setSaving] = useState(false); + + async function updateWorkingDirectory(action: 'choose' | 'clear') { + const releaseSave = persistGuard.begin('working-directory'); + if (!releaseSave) return; + setSaving(true); + try { + const defaultWorkingDirectory = + action === 'choose' + ? await window.maka.settings.chooseDefaultWorkingDirectory() + : undefined; + // A cancelled picker is not a request to clear the preference. + if (action === 'choose' && defaultWorkingDirectory === undefined) return; + await props.onUpdate({ projects: { defaultWorkingDirectory } }); + } catch (error) { + if (mountedRef.current) { + toast.error( + copy.saveDefaultWorkingDirectoryFailed, + settingsActionErrorMessage(error, locale), + ); + } + } finally { + releaseSave(); + if (mountedRef.current) setSaving(false); + } + } + + return ( + <> + + {copy.defaultWorkingDirectoryHelp} + + {props.defaultWorkingDirectory ?? copy.notSet} + + + } + /> + +