diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 14d7786e93..d38c7f221b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3086,7 +3086,7 @@ "@maka/core/model-thinking": 1, "@maka/core/settings": 3, "@maka/core/settings/network-settings": 1, - "@maka/ui": 2, + "@maka/ui": 1, "react": 1 } }, @@ -4230,10 +4230,10 @@ "dependencyPaths": { "../../preload/bridge-contract.js": 1, "../../shared/settings-ownership.js": 1, - "../browser-storage": 1, "../features/connection-settings": 1, "../locales/settings-navigation-copy.js": 1, "../locales/settings-shared-copy.js": 1, + "../platform/desktop/settings-surface-capabilities.js": 1, "./about-settings-page": 1, "./appearance-settings-page": 1, "./bot-chat-settings-page": 1, diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 9e09dbd51b..9d95537c55 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -2178,7 +2178,17 @@ function allowsMigrationDependency({ base, current, dependency, desktopRoot, pat const targetRelative = normalizePath(relative(desktopRoot, target)); const targetZone = zoneFor(targetRelative); if (section === 'legacyAppShell' || section === 'legacyAppShellClosure') { - if (targetZone.kind === 'shell' || isPublicApplicationPath(targetRelative)) return true; + // `platform` counts for the same reason the root closure already accepts it: + // a Desktop adapter is the only zone allowed to own bridge and browser + // capabilities, so moving a capability out of legacy renderer code and + // behind an adapter is a debt-reducing move, not new debt. + if ( + targetZone.kind === 'shell' || + targetZone.kind === 'platform' || + isPublicApplicationPath(targetRelative) + ) { + return true; + } const targetFeature = featureForAbsolutePath(desktopRoot, target); return Boolean(targetFeature && isPublicFeaturePath(targetFeature.subpath)); } diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 27caed53af..42c48a151e 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -2244,6 +2244,52 @@ describe('renderer architecture checker fixtures', () => { ); }); + it('allows legacy AppShell dependency replacement with a Desktop adapter', async () => { + const appShellPath = 'src/renderer/app-shell.tsx'; + const appShellSource = ` + import { readSetting } from './platform/desktop/read-setting.js'; + export const AppShell = readSetting; + `; + const currentDebt = debtForSource(appShellSource, appShellPath); + const baseDebt = { + ...currentDebt, + dependencyPaths: { './legacy-setting-owner.js': 1 }, + }; + const ownership = [ + { + capability: 'fixture-app-shell', + targetZone: 'shell', + legacyPaths: [appShellPath], + }, + ]; + const currentConfig = architectureConfig({ + legacyFiles: { [appShellPath]: currentDebt }, + legacyRendererFiles: [appShellPath], + ownership, + }); + const baseConfig = architectureConfig({ + legacyFiles: { [appShellPath]: baseDebt }, + legacyRendererFiles: [appShellPath], + ownership, + }); + + await withDesktopFixture( + { + [appShellPath]: appShellSource, + // A Desktop adapter is the only zone allowed to own the bridge, so + // handing a capability to one is how legacy renderer debt gets paid off. + 'src/renderer/platform/desktop/read-setting.ts': ` + export function readSetting(): string | undefined { + return window.maka.settings.getClient === undefined ? undefined : 'set'; + } + `, + }, + (desktopRoot) => { + assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []); + }, + ); + }); + it('rejects replacing legacy AppShell debt with a feature private import', async () => { const appShellPath = 'src/renderer/app-shell.tsx'; const appShellSource = ` 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..4528c0e0f9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/general-settings-default-working-directory.test.ts @@ -0,0 +1,113 @@ +/* + * 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 + * Host-backed Runtime Host cannot own it and its ProjectRootController never + * receives `defaultWorkingDirectory`, so offering the control there would let a + * user save a path the target is incapable of using. + * + * These tests pin two things: the capability gate agrees with the boundary the + * main process uses to build `setLocalDefault`, and the save lands in the + * client-owned (per-machine) `projects` section rather than anything + * Host-shared. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { UpdateAppSettingsInput } from '@maka/core/settings'; +import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; +import { + canSetLocalDefaultWorkingDirectory, + resolveDefaultWorkingDirectoryPatch, +} from '../../renderer/platform/desktop/settings-surface-capabilities.js'; + +const CHOSEN_DIRECTORY = '/Users/example/picked'; + +const PROFILE_KINDS: readonly RuntimeHostProfileKind[] = ['local', 'environment', 'remote']; + +test('only a client-owned workspace may set a local default directory', () => { + assert.equal(canSetLocalDefaultWorkingDirectory('local'), true); + assert.equal(canSetLocalDefaultWorkingDirectory('environment'), false); + assert.equal(canSetLocalDefaultWorkingDirectory('remote'), false); +}); + +test('the gate tracks the same boundary the main process derives setLocalDefault from', () => { + // `runtime-host-boot.ts` builds `setLocalDefault: !usesHostWorkspace`. If a new + // profile kind ever disagrees with that, the control would offer to save a + // path its target cannot use. + for (const kind of PROFILE_KINDS) { + assert.equal( + canSetLocalDefaultWorkingDirectory(kind), + !runtimeHostProfileUsesHostWorkspace(kind), + `gate disagrees with the main-process capability for ${kind}`, + ); + } +}); + +test('an unknown target cannot set the directory', () => { + // No selected Runtime Host means no answer yet; staying hidden beats guessing. + assert.equal(canSetLocalDefaultWorkingDirectory(undefined), false); +}); + +test('choosing a folder patches the client-owned Project preferences', async () => { + const { patches, pickerCalls } = await runRowAction('choose', CHOSEN_DIRECTORY); + + assert.deepEqual(patches, [{ projects: { defaultWorkingDirectory: CHOSEN_DIRECTORY } }]); + assert.equal(pickerCalls, 1); +}); + +test('a cancelled picker is not a request to clear the directory', async () => { + const { patches, pickerCalls } = await runRowAction('choose', undefined); + + assert.equal(pickerCalls, 1); + assert.deepEqual(patches, []); +}); + +test('clearing sends an undefined directory and never opens a picker', async () => { + const { patches, pickerCalls } = await runRowAction('clear', CHOSEN_DIRECTORY); + + assert.deepEqual(patches, [{ projects: { defaultWorkingDirectory: undefined } }]); + assert.equal(pickerCalls, 0); +}); + +/** + * Drives the row's real decision function. + * + * `GeneralDefaultsCard` renders the row inline and reaches the outside world only + * through the `onSaveWorkingDirectory` callback the Settings surface passes in. + * `resolveDefaultWorkingDirectoryPatch` is the decision behind that callback, so + * exercising it pins what the row actually owns: which patch it sends, and + * whether it opens a picker at all. + */ +async function runRowAction( + action: 'choose' | 'clear', + chosenDirectory: string | undefined, +): Promise<{ patches: UpdateAppSettingsInput[]; pickerCalls: number }> { + const patches: UpdateAppSettingsInput[] = []; + let pickerCalls = 0; + + const patch = await resolveDefaultWorkingDirectoryPatch(action, async () => { + pickerCalls += 1; + return chosenDirectory; + }); + if (patch) patches.push(patch); + + return { patches, pickerCalls }; +} diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index fef7a9cb70..add41f3a86 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -31,6 +31,7 @@ import { createProjectManagementService, type ProjectManagementCatalog, } from '../project-management-service.js'; +import { createProjectRootController } from '../project-root-controller.js'; const LOCAL_CAPABILITIES = { chooseClientDirectory: true, @@ -213,6 +214,7 @@ test('keeps an explicit no-Project selection local to Desktop', async () => { test('does not silently replace a stale Project preference with another Project', async () => { const selections: Array<{ projectId: string | null; path: string }> = []; + let currentSelection = { projectId: 'missing' as string | null, path: '/last-known' }; const service = createProjectManagementService({ capabilities: LOCAL_CAPABILITIES, catalog: { @@ -225,8 +227,11 @@ test('does not silently replace a stale Project preference with another Project' }, chooseDirectory: async () => undefined, selection: { - currentSelection: async () => ({ projectId: 'missing', path: '/last-known' }), - setSelection: (projectId, path) => selections.push({ projectId, path }), + currentSelection: async () => currentSelection, + setSelection: (projectId, path) => { + currentSelection = { projectId, path }; + selections.push({ projectId, path }); + }, }, }); @@ -234,6 +239,46 @@ test('does not silently replace a stale Project preference with another Project' assert.deepEqual(selections, [{ projectId: null, path: '/last-known' }]); }); +test('a stale Project preference recovers through the configured default directory', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-stale-default-')); + const fallback = join(base, 'fallback'); + const configuredDefault = join(base, 'configured-default'); + const preferenceFile = join(base, 'project-preferences.json'); + await Promise.all([mkdir(fallback), mkdir(configuredDefault)]); + await writeFile( + preferenceFile, + JSON.stringify({ version: 1, selections: { 'root-a': 'deleted-project' } }), + ); + const selection = createProjectRootController({ + rootId: 'root-a', + preferenceFile, + fallbackRoots: () => [fallback], + defaultWorkingDirectory: async () => configuredDefault, + }); + const service = createProjectManagementService({ + capabilities: LOCAL_CAPABILITIES, + catalog: { + list: async () => [], + register: unexpected, + relink: unexpected, + rename: unexpected, + archive: unexpected, + restore: unexpected, + }, + chooseDirectory: async () => undefined, + selection, + }); + + try { + assert.deepEqual(await service.current(), { + projectId: null, + path: configuredDefault, + }); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + test('does not expose Client directory actions for a remote Host', async () => { let pickerCalls = 0; const directoryRequests: unknown[] = []; 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-management-service.ts b/apps/desktop/src/main/project-management-service.ts index 9b5effd22f..0e712ee026 100644 --- a/apps/desktop/src/main/project-management-service.ts +++ b/apps/desktop/src/main/project-management-service.ts @@ -98,8 +98,8 @@ export function createProjectManagementService(deps: { : undefined; if (!requested) { if (typeof selectedProjectId === 'string') { - deps.selection.setSelection(null, selection.path); - return { projectId: null, path: selection.path }; + await deps.selection.setSelection(null, selection.path); + return deps.selection.currentSelection(); } return { projectId: undefined, path: selection.path }; } 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 6575ccc5aa..81fc1d665d 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -141,7 +141,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, @@ -1252,6 +1255,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, @@ -1598,6 +1607,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 2bc4ce1aad..e2b98397a1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1410,6 +1410,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 c3a7726846..0b4225dbca 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2997,6 +2997,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/platform/desktop/settings-surface-capabilities.ts b/apps/desktop/src/renderer/platform/desktop/settings-surface-capabilities.ts new file mode 100644 index 0000000000..9b379d5380 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/settings-surface-capabilities.ts @@ -0,0 +1,96 @@ +/* + * 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 { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; + +/** + * Desktop adapters for the Settings surface. + * + * Settings pages are legacy-zone renderer code and must not own Desktop + * capabilities directly. The surface takes them from here and passes plain + * values and callbacks down, so no Settings page reaches for `window.maka` or + * web storage itself. + */ + +/** + * Whether the selected Runtime Host can own a local default working directory. + * + * This is the same boundary the Projects page's `setLocalDefault` capability + * reports: the main process derives that flag from + * `runtimeHostProfileUsesHostWorkspace` when it builds the project management + * service, so the selected profile's kind answers it here without a second + * capability round-trip. Host-backed targets never receive + * `defaultWorkingDirectory` in their ProjectRootController, so the control stays + * hidden for them rather than saving a path the target cannot use. + */ +export function canSetLocalDefaultWorkingDirectory( + profileKind: RuntimeHostProfileKind | undefined, +): boolean { + if (profileKind === undefined) return false; + return !runtimeHostProfileUsesHostWorkspace(profileKind); +} + +/** + * Opens the Desktop directory picker for the default working directory. + * + * Resolves `undefined` when the user cancels, which callers must treat as "leave + * the preference alone" rather than as a request to clear it. + */ +export function chooseDefaultWorkingDirectory(): Promise { + return window.maka.settings.chooseDefaultWorkingDirectory(); +} + +/** + * Persists the last Settings section the user had open. + * + * Web-storage access is a Desktop environment capability, so the Settings + * surface routes it through this adapter rather than reaching for `localStorage` + * itself. Failures are swallowed: storage is unavailable in restricted and test + * renderer contexts, and remembering the section is a convenience, never a + * correctness requirement. + */ +export function persistSettingsSection(section: string): void { + try { + localStorage.setItem('maka-settings-section-v1', section); + } catch { + // Storage may be unavailable in restricted or test renderer contexts. + } +} + +/** + * Resolves what a default-working-directory action should persist. + * + * Returns the settings patch to send, or `undefined` when nothing should be + * saved. The cancel case is why this is a decision rather than a straight-line + * save: a dismissed picker resolves `undefined`, and treating that as a value + * would silently clear a directory the user still wants. The value goes into the + * client-owned `projects` section, so it stays per-machine rather than shared + * with every other client of a Runtime Host — a working directory only exists on + * one filesystem. + */ +export async function resolveDefaultWorkingDirectoryPatch( + action: 'choose' | 'clear', + choose: () => Promise, +): Promise<{ projects: { defaultWorkingDirectory?: string } } | undefined> { + if (action === 'clear') return { projects: { defaultWorkingDirectory: undefined } }; + const defaultWorkingDirectory = await choose(); + if (defaultWorkingDirectory === undefined) return undefined; + return { projects: { defaultWorkingDirectory } }; +} diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 42b0a0dd17..b320d95b70 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -56,10 +56,10 @@ import { useToast, useUiLocale, Banner, + getConversationCopy, } from "@maka/ui"; import { ProviderBrandMark } from "./provider-brand-marks"; import { PasswordInput } from "./password-input"; -import { getConversationCopy } from '@maka/ui'; import { settingsActionErrorMessage } from "./settings-error-copy"; import { useActionGuard, useKeyedActionGuard } from "./use-action-guard"; import { useOptimisticSettingsDraft } from "./use-optimistic-settings-draft"; @@ -89,6 +89,13 @@ export function GeneralSettingsPage(props: { onUpdate( patch: Parameters[0], ): Promise; + // The default-working-directory row is gated on the same capability the + // Projects page uses for its own local-only default: remote targets advertise + // `setLocalDefault: false` and their ProjectRootController never receives + // `defaultWorkingDirectory`, so the control stays hidden there. The surface + // owns the project-snapshot read; this page stays free of bridge access. + canSetLocalDefault: boolean; + onSaveWorkingDirectory(action: 'choose' | 'clear'): Promise; onRefreshConnections(): Promise; onRetryRuntimeHost(): Promise; }) { @@ -300,6 +307,9 @@ export function GeneralSettingsPage(props: { onRefresh={props.onRefreshConnections} permissionMode={props.settings.chatDefaults.permissionMode} thinkingLevel={props.settings.chatDefaults.thinkingLevel} + defaultWorkingDirectory={props.settings.projects.defaultWorkingDirectory} + canSetLocalDefault={props.canSetLocalDefault} + onSaveWorkingDirectory={props.onSaveWorkingDirectory} onUpdate={props.onUpdate} /> ) : null} @@ -496,6 +506,9 @@ function GeneralDefaultsCard(props: { onRefresh(): Promise; permissionMode: ChatDefaultPermissionMode; thinkingLevel?: ThinkingLevel; + defaultWorkingDirectory?: string; + canSetLocalDefault: boolean; + onSaveWorkingDirectory(action: 'choose' | 'clear'): Promise; onUpdate( patch: Parameters[0], ): Promise; @@ -511,11 +524,14 @@ function GeneralDefaultsCard(props: { const toast = useToast(); const mountedRef = useMountedRef(); const persistGuard = useKeyedActionGuard< - "default-model" | "permission-mode" | "thinking-level" + "default-model" | "permission-mode" | "thinking-level" | "working-directory" >(); const [saving, setSaving] = useState(false); const [savingPermissionMode, setSavingPermissionMode] = useState(false); const [savingThinkingLevel, setSavingThinkingLevel] = useState(false); + // The working-directory row reuses `saving`: `persistGuard` already makes the + // card's async actions mutually exclusive, so a second boolean would only + // duplicate the latch it already owns. const modelChoices = useMemo( () => buildChatModelChoices(props.connections), @@ -644,11 +660,78 @@ function GeneralDefaultsCard(props: { } } + // Inlined into this card rather than extracted into its own settings module: + // the renderer architecture ratchet forbids a new legacy-zone file becoming + // reachable from AppShell, and the row needs no capability this card does not + // already own. + async function persistWorkingDirectory(action: 'choose' | 'clear') { + const releaseSave = persistGuard.begin('working-directory'); + if (!releaseSave) return; + setSaving(true); + try { + await props.onSaveWorkingDirectory(action); + } catch (error) { + if (mountedRef.current) { + toast.error( + copy.saveDefaultWorkingDirectoryFailed, + settingsActionErrorMessage(error, locale), + undefined, + host ? { profileId: host.profileId } : undefined, + ); + } + } finally { + releaseSave(); + if (mountedRef.current) setSaving(false); + } + } + return ( + {/* Local-only and client-owned: gated on the same capability the + Projects page uses for its own default. 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 rather than shared with every other + client of a Runtime Host — a working directory only exists on one + filesystem. */} + {props.canSetLocalDefault ? ( + <> + + {copy.defaultWorkingDirectoryHelp} + + {props.defaultWorkingDirectory ?? copy.notSet} + + + } + /> + +