From abda4bb36a5ad60b0dd360ee3cbcc1376f39c943 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:36:20 +0800 Subject: [PATCH 1/4] feat(composer): workspace picker with recent directories, git branch switching, and composer defaults persistence --- apps/desktop/src/global.d.ts | 21 +++ ...ser-new-chat-model-picker-contract.test.ts | 18 +- .../src/main/__tests__/git-branch.test.ts | 165 ++++++++++++++++++ .../main/__tests__/streaming-handoff.test.ts | 1 + apps/desktop/src/main/git-branch.ts | 142 +++++++++++++++ apps/desktop/src/main/main.ts | 76 +++++++- apps/desktop/src/preload/preload.ts | 29 +++ .../src/renderer/app-shell-chat-actions.ts | 30 ++-- .../src/renderer/app-shell-project-actions.ts | 94 ++++++++++ .../app-shell-session-settings-actions.ts | 7 + apps/desktop/src/renderer/app-shell.tsx | 59 ++++++- .../desktop/src/renderer/composer-defaults.ts | 84 +++++++++ apps/desktop/src/renderer/styles/composer.css | 72 ++++++++ apps/desktop/stories/app-shell.stories.tsx | 11 +- packages/core/src/session.ts | 2 + packages/runtime/src/session-manager.ts | 1 + packages/storage/src/session-store.ts | 1 + packages/ui/src/composer.tsx | 160 ++++++++++++++--- packages/ui/src/icons.tsx | 1 + packages/ui/stories/chat-surface.stories.tsx | 11 +- 20 files changed, 928 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/git-branch.test.ts create mode 100644 apps/desktop/src/main/git-branch.ts create mode 100644 apps/desktop/src/renderer/composer-defaults.ts diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a029752f43..44d5bc9333 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -433,6 +433,27 @@ declare global { | { ok: true; projectPath: string; projectGit: { isGitRepo: boolean; branch?: string } } | { ok: false; reason: 'cancelled' | 'missing-selection' } >; + selectProjectRoot(projectPath: string): Promise< + | { ok: true; projectPath: string; projectGit: { isGitRepo: boolean; branch?: string } } + | { ok: false; reason: 'invalid-path' | 'not-found' } + >; + resolveProjectGitInfo(projectPath: string): Promise<{ + projectPath: string; + projectGit: { isGitRepo: boolean; branch?: string }; + }>; + listGitBranches(): Promise<{ + ok: boolean; + branches?: string[]; + current?: string; + reason?: string; + message?: string; + }>; + checkoutGitBranch(branch: string): Promise<{ + ok: boolean; + branch?: string; + reason?: string; + message?: string; + }>; openArtifactPath( artifactId: string, ): Promise< diff --git a/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts b/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts index f08bbea964..012cfdc629 100644 --- a/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts +++ b/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts @@ -93,8 +93,8 @@ describe('home composer new-chat model picker', () => { ); assert.match( renderer, - /onPickNewChatModel=\{\(input\) => setPendingNewChatModel\(input\)\}/, - 'main.tsx must wire the composer pick to setPendingNewChatModel', + /onPickNewChatModel=\{\(input\) => \{[\s\S]*setPendingNewChatModel\(input\);[\s\S]*saveComposerDefaults\(\{ model: input \}\);[\s\S]*\}\}/, + 'main.tsx must wire the composer pick to state and persisted composer defaults', ); // A pick only stays in effect while it is still an offered choice; once the // connection/model is removed the picker must fall back to the default so it @@ -120,8 +120,18 @@ describe('home composer new-chat model picker', () => { assert.match( renderer, - /const \[pendingNewChatPermissionMode, setPendingNewChatPermissionMode\] = useState\(null\)/, - 'AppShell must keep the picked empty-state permission mode in renderer-only state', + /const \[pendingNewChatPermissionMode, setPendingNewChatPermissionMode\] = useState\([\s\S]*persistedComposerDefaults\?\.permissionMode \?\? null,[\s\S]*\)/, + 'AppShell must seed the picked empty-state permission mode from persisted composer defaults', + ); + assert.match( + renderer, + /setPendingNewChatPermissionMode: \(mode: PendingNewChatPermissionMode\) => void;/, + 'createAppShellChatActions deps must include setPendingNewChatPermissionMode so send() can reset the one-shot pick', + ); + assert.match( + renderer, + /setPendingNewChatPermissionMode,[\s\S]*validPendingNewChatModel,/, + 'createAppShellChatActions must destructure setPendingNewChatPermissionMode before send() calls it', ); assert.match( setPermissionModeBlock, diff --git a/apps/desktop/src/main/__tests__/git-branch.test.ts b/apps/desktop/src/main/__tests__/git-branch.test.ts new file mode 100644 index 0000000000..ae6a9e6e7a --- /dev/null +++ b/apps/desktop/src/main/__tests__/git-branch.test.ts @@ -0,0 +1,165 @@ +import { strict as assert } from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { ExecFileException } from 'node:child_process'; +import { listLocalBranches, checkoutBranch } from '../git-branch.js'; + +type ExecFileCallback = ( + file: string, + args: readonly string[], + options: { cwd: string; timeout: number; windowsHide: boolean }, + cb: (error: ExecFileException | null, stdout: string, stderr: string) => void, +) => void; + +function fakeExecFile(onExecute: (args: readonly string[]) => { + error: ExecFileException | null; + stdout: string; + stderr: string; +}): ExecFileCallback { + return ( + _file: string, + args: readonly string[], + _options: { cwd: string; timeout: number; windowsHide: boolean }, + cb: (error: ExecFileException | null, stdout: string, stderr: string) => void, + ) => { + const result = onExecute(args); + cb(result.error, result.stdout, result.stderr); + }; +} + +function errnoException(code: string, message?: string): ExecFileException { + const err = new Error(message ?? code) as NodeJS.ErrnoException & ExecFileException; + err.code = code; + err.name = 'Error'; + return err as unknown as ExecFileException; +} + +async function withGitRepo(run: (gitRoot: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-git-test-')); + await mkdir(join(root, '.git'), { recursive: true }); + await writeFile(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8'); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +describe('listLocalBranches', () => { + it('parses `git branch --list` output with current branch marked by *', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeExecFile((args) => { + assert.deepEqual(args, ['branch', '--list']); + return { error: null, stdout: '* main\n develop\n feature/sidebar\n', stderr: '' }; + }); + const result = await listLocalBranches(root, { execFileImpl }); + assert.equal(result.ok, true); + assert.deepEqual(result.branches, ['main', 'develop', 'feature/sidebar']); + assert.equal(result.current, 'main'); + }); + }); + + it('detects detached HEAD (no * branch)', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeExecFile(() => ({ + error: null, + stdout: '* (HEAD detached at abc1234)\n main\n develop\n', + stderr: '', + })); + const result = await listLocalBranches(root, { execFileImpl }); + assert.equal(result.ok, true); + assert.deepEqual(result.branches, ['main', 'develop']); + assert.equal(result.current, undefined); + }); + }); + + it('handles a single-branch repo', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeExecFile(() => ({ + error: null, + stdout: '* main\n', + stderr: '', + })); + const result = await listLocalBranches(root, { execFileImpl }); + assert.equal(result.ok, true); + assert.deepEqual(result.branches, ['main']); + assert.equal(result.current, 'main'); + }); + }); + + it('deduplicates branch names', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeExecFile(() => ({ + error: null, + stdout: '* main\n develop\n develop\n', + stderr: '', + })); + const result = await listLocalBranches(root, { execFileImpl }); + assert.equal(result.ok, true); + assert.deepEqual(result.branches, ['main', 'develop']); + assert.equal(result.current, 'main'); + }); + }); + + it('returns not-a-repo when project lacks .git metadata', async () => { + const result = await listLocalBranches('/tmp/non-existent'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'not-a-repo'); + }); + + it('returns missing-git when git binary is not found (ENOENT)', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeExecFile(() => ({ + error: errnoException('ENOENT', 'spawn git ENOENT'), + stdout: '', + stderr: '', + })); + const result = await listLocalBranches(root, { execFileImpl }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing-git'); + }); + }); + + it('returns timeout when git takes too long', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeExecFile(() => ({ + error: Object.assign(errnoException('ETIMEDOUT', 'timeout'), { killed: true }), + stdout: '', + stderr: '', + })); + const result = await listLocalBranches(root, { execFileImpl }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'timeout'); + }); + }); +}); + +describe('checkoutBranch', () => { + it('switches to a valid branch and returns the new branch name', async () => { + await withGitRepo(async (root) => { + let gitArgs: readonly string[] = []; + const execFileImpl = fakeExecFile((args) => { + gitArgs = args; + return { error: null, stdout: '', stderr: '' }; + }); + const result = await checkoutBranch(root, 'develop', { execFileImpl }); + assert.ok(result.ok); + assert.deepEqual(gitArgs, ['checkout', 'develop']); + }); + }); + + it('rejects invalid branch names', async () => { + const result = await checkoutBranch('/fake/repo', 'bad; branch'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'failed'); + assert.match(result.message ?? '', /无效的分支名/); + }); + + it('rejects empty branch name', async () => { + const result = await checkoutBranch('/fake/repo', ''); + assert.equal(result.ok, false); + assert.equal(result.reason, 'failed'); + }); +}); diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index b99155ab35..b5a1038db5 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -160,6 +160,7 @@ describe('assistant streaming handoff', () => { isNewChatSendSurfaceActive: () => false, markSessionReadLocally: () => {}, messageRetryPendingRef: { current: new Set() }, + projectPath: null, refreshSessions: async () => [], setActiveId: (sessionId) => { activeIdRef.current = sessionId; diff --git a/apps/desktop/src/main/git-branch.ts b/apps/desktop/src/main/git-branch.ts new file mode 100644 index 0000000000..5e02affce4 --- /dev/null +++ b/apps/desktop/src/main/git-branch.ts @@ -0,0 +1,142 @@ +import { execFile, type ExecFileException } from 'node:child_process'; +import { resolveProjectGitInfo } from './project-context.js'; + +/** + * git-branch.ts — local-only git branch listing and checkout for the + * desktop main process. We deliberately shell out to `git` here (unlike + * build-info.ts, which reads `.git` metadata directly to avoid coupling + * the build-time module to a `git` binary). Branch switching needs the + * real git CLI to honor worktrees, hooks, and conflict semantics. + * + * The execFile shape mirrors officecli-probe.ts: an injectable + * `execFileImpl` lets unit tests fake the child process. + */ + +export type GitBranchReason = 'missing-git' | 'not-a-repo' | 'failed' | 'timeout' | 'dirty'; + +export interface GitBranchListResult { + ok: boolean; + branches?: string[]; + current?: string; + reason?: GitBranchReason; + message?: string; +} + +export interface GitCheckoutResult { + ok: boolean; + branch?: string; + reason?: GitBranchReason; + message?: string; +} + +const LIST_TIMEOUT_MS = 3_000; +const CHECKOUT_TIMEOUT_MS = 10_000; + +type ExecFileCallback = ( + file: string, + args: readonly string[], + options: { cwd: string; timeout: number; windowsHide: boolean }, + cb: (error: ExecFileException | null, stdout: string, stderr: string) => void, +) => void; + +function classifyError(error: ExecFileException | null): GitBranchReason | undefined { + if (!error) return undefined; + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return 'missing-git'; + if (code === 'ETIMEDOUT' || (error as { killed?: boolean }).killed) return 'timeout'; + return 'failed'; +} + +function runGit( + input: { + cwd: string; + args: readonly string[]; + timeoutMs: number; + execFileImpl?: ExecFileCallback; + }, +): Promise<{ stdout: string; stderr: string; error: ExecFileException | null }> { + const execFileImpl = input.execFileImpl ?? (execFile as unknown as ExecFileCallback); + return new Promise((resolve) => { + execFileImpl( + 'git', + input.args, + { cwd: input.cwd, timeout: input.timeoutMs, windowsHide: true }, + (error, stdout, stderr) => resolve({ stdout, stderr, error }), + ); + }); +} + +export async function listLocalBranches( + projectRoot: string, + input: { execFileImpl?: ExecFileCallback } = {}, +): Promise { + // Guard against non-repos so we never spawn `git` inside an unrelated + // ancestor (currentProjectRoot can fall back to process.cwd()). + const info = await resolveProjectGitInfo(projectRoot); + if (!info.isGitRepo) return { ok: false, reason: 'not-a-repo' }; + + const { stdout, stderr, error } = await runGit({ + cwd: projectRoot, + args: ['branch', '--list'], + timeoutMs: LIST_TIMEOUT_MS, + execFileImpl: input.execFileImpl, + }); + if (error) { + const reason = classifyError(error); + return { ok: false, reason, message: stderr.trim() || error.message }; + } + + const branches: string[] = []; + let current: string | undefined; + for (const raw of stdout.split('\n')) { + const line = raw.trimEnd(); + if (!line) continue; + const isCurrent = line.startsWith('* '); + const name = (isCurrent ? line.slice(2) : line).trim(); + if (!name || name.startsWith('(')) continue; // skip detached `(HEAD detached at …)` + if (!branches.includes(name)) branches.push(name); + if (isCurrent) current = name; + } + return { ok: true, branches, current }; +} + +export async function checkoutBranch( + projectRoot: string, + branch: string, + input: { execFileImpl?: ExecFileCallback } = {}, +): Promise { + if (!branch || typeof branch !== 'string' || /[\s`$&;|<>]/.test(branch)) { + return { ok: false, reason: 'failed', message: '无效的分支名' }; + } + + const info = await resolveProjectGitInfo(projectRoot); + if (!info.isGitRepo) return { ok: false, reason: 'not-a-repo' }; + + // Guard against dirty worktree: refuse checkout when there are + // uncommitted changes the user could lose or mix up. + const { stdout: statusOutput } = await runGit({ + cwd: projectRoot, + args: ['status', '--porcelain'], + timeoutMs: LIST_TIMEOUT_MS, + execFileImpl: input.execFileImpl, + }); + if (statusOutput.trim()) { + return { ok: false, reason: 'dirty', message: '工作区有未提交的更改,请先提交或暂存。' }; + } + + const { stderr, error } = await runGit({ + cwd: projectRoot, + args: ['checkout', branch], + timeoutMs: CHECKOUT_TIMEOUT_MS, + execFileImpl: input.execFileImpl, + }); + if (error) { + const reason = classifyError(error); + return { ok: false, reason, message: stderr.trim() || error.message }; + } + + // Re-read HEAD to confirm the switch actually landed (handles race + // conditions where another process moved HEAD between checkout and now). + const after = await resolveProjectGitInfo(projectRoot); + return { ok: true, branch: after.branch ?? branch }; +} \ No newline at end of file diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 678009e402..bc2040c90f 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,6 +1,6 @@ import { app, ipcMain, nativeImage, safeStorage, shell } from 'electron'; import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, realpath } from 'node:fs/promises'; +import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { release as osRelease, arch as osArch } from 'node:os'; import { @@ -108,6 +108,7 @@ import { handleQuickChatStart as runQuickChatStart, type QuickChatResult } from import { probeOfficeCli } from './officecli-probe.js'; import { resolveOpenPath, type OpenPathResult } from './open-path-guard.js'; import { resolveProjectGitInfo, resolveProjectRoot } from './project-context.js'; +import { listLocalBranches, checkoutBranch } from './git-branch.js'; import { createDailyReviewArchiveStore } from './daily-review-archive-store.js'; import { botTestErrorMessage, buildSettingsUpdateResult, maskAppSettings, preserveSensitivePlaceholders, toSettingsTestResult } from './settings-ipc-helpers.js'; import { @@ -790,8 +791,36 @@ function proxyTestFailureMessage(result: TestProxyResult): string { } function registerIpc(): void { + const LAST_PROJECT_PATH_FILE = join(workspaceRoot, 'last-project-path.json'); + let selectedProjectRoot: string | null = null; + async function loadLastProjectPath(): Promise { + try { + const raw = await readFile(LAST_PROJECT_PATH_FILE, 'utf8'); + const parsed = JSON.parse(raw) as Record; + if (typeof parsed.projectPath === 'string' && parsed.projectPath) { + return parsed.projectPath; + } + } catch { + // File missing or invalid — first run. + } + return null; + } + + async function saveLastProjectPath(projectPath: string): Promise { + try { + await writeFile(LAST_PROJECT_PATH_FILE, JSON.stringify({ projectPath }), 'utf8'); + } catch { + // Best-effort; failure should not block the selection. + } + } + + // Initialize from persisted state. + loadLastProjectPath().then((savedPath) => { + if (savedPath) selectedProjectRoot = savedPath; + }); + async function currentProjectRoot(): Promise { if (selectedProjectRoot) return selectedProjectRoot; return resolveProjectRoot([process.cwd(), app.getAppPath()]); @@ -848,6 +877,7 @@ function registerIpc(): void { if (!selectedPath) return { ok: false, reason: 'missing-selection' }; const projectPath = await resolveProjectRoot([selectedPath]); selectedProjectRoot = projectPath; + void saveLastProjectPath(projectPath); return { ok: true, projectPath, @@ -855,6 +885,50 @@ function registerIpc(): void { }; }, ); + ipcMain.handle( + 'app:selectProjectRoot', + async (_event, projectPath: unknown): Promise< + | { ok: true; projectPath: string; projectGit: Awaited> } + | { ok: false; reason: 'invalid-path' | 'not-found' } + > => { + if (typeof projectPath !== 'string' || !projectPath) { + return { ok: false, reason: 'invalid-path' }; + } + try { + const resolved = await resolveProjectRoot([projectPath]); + selectedProjectRoot = resolved; + void saveLastProjectPath(resolved); + return { + ok: true, + projectPath: resolved, + projectGit: await resolveProjectGitInfo(resolved), + }; + } catch { + return { ok: false, reason: 'not-found' }; + } + }, + ); + ipcMain.handle( + 'app:resolveProjectGitInfo', + async (_event, projectPath: unknown): Promise<{ projectPath: string; projectGit: Awaited> }> => { + const resolved = typeof projectPath === 'string' ? await resolveProjectRoot([projectPath]) : await currentProjectRoot(); + return { projectPath: resolved, projectGit: await resolveProjectGitInfo(resolved) }; + }, + ); + ipcMain.handle('app:listGitBranches', async () => { + const projectPath = await currentProjectRoot(); + return listLocalBranches(projectPath); + }); + ipcMain.handle( + 'app:checkoutGitBranch', + async (_event, branch: unknown): Promise<{ ok: boolean; branch?: string; reason?: string; message?: string }> => { + if (typeof branch !== 'string' || !branch) { + return { ok: false, reason: 'failed', message: '无效的分支名' }; + } + const projectPath = await currentProjectRoot(); + return checkoutBranch(projectPath, branch); + }, + ); registerMemoryIpc({ localMemory }); ipcMain.handle('workspaceInstructions:getState', () => getWorkspaceInstructionsState(process.cwd())); ipcMain.handle( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 19b9356ebf..efaca7e561 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -762,6 +762,35 @@ contextBridge.exposeInMainWorld('maka', { > { return ipcRenderer.invoke('app:selectProjectDirectory'); }, + selectProjectRoot(projectPath: string): Promise< + | { ok: true; projectPath: string; projectGit: { isGitRepo: boolean; branch?: string } } + | { ok: false; reason: 'invalid-path' | 'not-found' } + > { + return ipcRenderer.invoke('app:selectProjectRoot', projectPath); + }, + resolveProjectGitInfo(projectPath: string): Promise<{ + projectPath: string; + projectGit: { isGitRepo: boolean; branch?: string }; + }> { + return ipcRenderer.invoke('app:resolveProjectGitInfo', projectPath); + }, + listGitBranches(): Promise<{ + ok: boolean; + branches?: string[]; + current?: string; + reason?: string; + message?: string; + }> { + return ipcRenderer.invoke('app:listGitBranches'); + }, + checkoutGitBranch(branch: string): Promise<{ + ok: boolean; + branch?: string; + reason?: string; + message?: string; + }> { + return ipcRenderer.invoke('app:checkoutGitBranch', branch); + }, openArtifactPath( artifactId: string, ): Promise< diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 4719fcec23..39e8cae4b1 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -93,6 +93,10 @@ export function createAppShellChatActions(deps: { isNewChatSendSurfaceActive: (owner: ComposerImportOwner) => boolean; markSessionReadLocally: (sessionId: string, readMessages: readonly StoredMessage[]) => void; messageRetryPendingRef: RefBox>; + pendingNewChatPermissionMode: PendingNewChatPermissionMode; + setPendingNewChatPermissionMode: (mode: PendingNewChatPermissionMode) => void; + /** Persisted project path to forward as the new session's `cwd`. */ + projectPath: string | null; refreshSessions: () => Promise; setActiveId: (sessionId: string | undefined) => void; setMessageLoadErrorBySession: MessageLoadErrorUpdater; @@ -102,8 +106,6 @@ export function createAppShellChatActions(deps: { showModelSetupToast: (description: string, reason?: string) => void; toastApi: ToastApi; upsertSessionSummary: (session: SessionSummary) => void; - pendingNewChatPermissionMode: PendingNewChatPermissionMode; - setPendingNewChatPermissionMode: (mode: PendingNewChatPermissionMode) => void; validPendingNewChatModel: PendingNewChatModel; }): AppShellChatActions { const { @@ -114,17 +116,18 @@ export function createAppShellChatActions(deps: { isNewChatSendSurfaceActive, markSessionReadLocally, messageRetryPendingRef, + pendingNewChatPermissionMode, + projectPath, refreshSessions, setActiveId, setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, setNavSelection, + setPendingNewChatPermissionMode, showModelSetupToast, toastApi, upsertSessionSummary, - pendingNewChatPermissionMode, - setPendingNewChatPermissionMode, validPendingNewChatModel, } = deps; @@ -171,14 +174,17 @@ export function createAppShellChatActions(deps: { try { const turnId = crypto.randomUUID(); if (!initialSessionId) { - const session = await window.maka.sessions.create({ - // Only send permissionMode when the user explicitly picked one in - // the composer. Omitting it lets main.ts's sessions:create resolve - // the configured chatDefaults.permissionMode as the single - // authority — a renderer-side copy of the default can be stale - // (e.g. before the mount-time settings load resolves on a cold - // start), which would silently override the configured setting. - ...(pendingNewChatPermissionMode ? { permissionMode: pendingNewChatPermissionMode } : {}), + const session = await window.maka.sessions.create({ + // Forward the composer's selected folder so new sessions actually run + // there; falls through to the main-process default when unset. + ...(projectPath ? { cwd: projectPath } : {}), + // Only send permissionMode when the user explicitly picked one in + // the composer. Omitting it lets main.ts's sessions:create resolve + // the configured chatDefaults.permissionMode as the single + // authority — a renderer-side copy of the default can be stale + // (e.g. before the mount-time settings load resolves on a cold + // start), which would silently override the configured setting. + ...(pendingNewChatPermissionMode ? { permissionMode: pendingNewChatPermissionMode } : {}), name: text.slice(0, 42) || '新建对话', ...(validPendingNewChatModel ? { llmConnectionSlug: validPendingNewChatModel.llmConnectionSlug, model: validPendingNewChatModel.model } diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts index 3d2099cb19..775e393258 100644 --- a/apps/desktop/src/renderer/app-shell-project-actions.ts +++ b/apps/desktop/src/renderer/app-shell-project-actions.ts @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { generalizedErrorMessageChinese } from '@maka/core'; import { basenameFromPath, openPathActionErrorMessage, selectProjectDirectoryFailureCopy } from './app-shell-copy'; import { openPathActionLabel, openPathFailureCopy } from './open-path'; +import { MAX_RECENT_PATHS, saveComposerDefaults } from './composer-defaults'; export interface RendererAppInfo { projectPath: string; @@ -18,9 +19,12 @@ type ToastApi = { export interface AppShellProjectActions { refreshAppInfo(): Promise; selectProjectDirectory(): Promise; + selectRecentProjectDirectory(path: string): Promise; openProjectFolder(): Promise; openWorkspaceFolder(): Promise; openSkillsFolder(): Promise; + listGitBranches(): Promise<{ branches: string[]; current?: string } | null>; + checkoutGitBranch(branch: string): Promise; } export function createAppShellProjectActions(deps: { @@ -29,6 +33,10 @@ export function createAppShellProjectActions(deps: { rendererMountedRef: RefBox; setAppInfo: Dispatch>; setProjectPickerPending: Dispatch>; + setBranchPending: Dispatch>; + setBranchList: Dispatch>; + setRecentProjectPaths: Dispatch>; + recentProjectPaths: string[]; toastApi: ToastApi; }): AppShellProjectActions { const { @@ -37,6 +45,10 @@ export function createAppShellProjectActions(deps: { rendererMountedRef, setAppInfo, setProjectPickerPending, + setBranchPending, + setBranchList, + setRecentProjectPaths, + recentProjectPaths, toastApi, } = deps; @@ -49,6 +61,12 @@ export function createAppShellProjectActions(deps: { } } + function addRecentProjectPath(path: string): void { + const next = [path, ...recentProjectPaths.filter((p) => p !== path)].slice(0, MAX_RECENT_PATHS); + setRecentProjectPaths(next); + saveComposerDefaults({ recentProjectPaths: next }); + } + async function selectProjectDirectory() { if (projectPickerPendingRef.current) return; const requestId = projectPickerRequestRef.current + 1; @@ -66,6 +84,41 @@ export function createAppShellProjectActions(deps: { return; } setAppInfo({ projectPath: result.projectPath, projectGit: result.projectGit }); + setBranchList(null); + // Persist so the next "新任务" inherits the folder (and it survives reload). + saveComposerDefaults({ projectPath: result.projectPath }); + addRecentProjectPath(result.projectPath); + toastApi.success('已切换工作目录', basenameFromPath(result.projectPath)); + } catch (error) { + if (isCurrentProjectPickerRequest()) { + toastApi.error('选择工作目录失败', generalizedErrorMessageChinese(error, '项目路径暂时无法读取,请稍后重试。')); + } + } finally { + if (projectPickerRequestRef.current === requestId) { + projectPickerPendingRef.current = false; + if (rendererMountedRef.current) setProjectPickerPending(false); + } + } + } + + async function selectRecentProjectDirectory(path: string) { + if (projectPickerPendingRef.current) return; + const requestId = projectPickerRequestRef.current + 1; + projectPickerRequestRef.current = requestId; + projectPickerPendingRef.current = true; + setProjectPickerPending(true); + const isCurrentProjectPickerRequest = () => rendererMountedRef.current && projectPickerRequestRef.current === requestId; + try { + const result = await window.maka.app.selectProjectRoot(path); + if (!isCurrentProjectPickerRequest()) return; + if (!result.ok) { + toastApi.error('选择工作目录失败', '所选路径不存在或不可读。'); + return; + } + setAppInfo({ projectPath: result.projectPath, projectGit: result.projectGit }); + setBranchList(null); + saveComposerDefaults({ projectPath: result.projectPath }); + addRecentProjectPath(result.projectPath); toastApi.success('已切换工作目录', basenameFromPath(result.projectPath)); } catch (error) { if (isCurrentProjectPickerRequest()) { @@ -112,11 +165,52 @@ export function createAppShellProjectActions(deps: { } } + async function listGitBranches(): Promise<{ branches: string[]; current?: string } | null> { + try { + const result = await window.maka.app.listGitBranches(); + if (!result.ok || !result.branches) { + if (result.reason && result.reason !== 'not-a-repo') { + toastApi.error('读取分支列表失败', result.message ?? '无法读取本地分支,请稍后重试。'); + } + return null; + } + const next = { branches: result.branches, current: result.current }; + setBranchList(next); + return next; + } catch (error) { + toastApi.error('读取分支列表失败', generalizedErrorMessageChinese(error, '无法读取本地分支,请稍后重试。')); + return null; + } + } + + async function checkoutGitBranch(branch: string): Promise { + if (!branch) return; + setBranchPending(true); + try { + const result = await window.maka.app.checkoutGitBranch(branch); + if (!result.ok) { + toastApi.error('切换分支失败', result.message ?? `无法切换到分支 ${branch}。`); + return; + } + setAppInfo((prev) => + prev ? { ...prev, projectGit: { isGitRepo: true, branch: result.branch ?? branch } } : prev, + ); + toastApi.success('已切换分支', result.branch ?? branch); + } catch (error) { + toastApi.error('切换分支失败', generalizedErrorMessageChinese(error, `无法切换到分支 ${branch}。`)); + } finally { + setBranchPending(false); + } + } + return { refreshAppInfo, selectProjectDirectory, + selectRecentProjectDirectory, openProjectFolder, openWorkspaceFolder, openSkillsFolder, + listGitBranches, + checkoutGitBranch, }; } diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index d48eb7dfdd..3b54515e1a 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -1,6 +1,7 @@ import type { LlmConnection, PermissionMode, SessionSummary } from '@maka/core'; import { generalizedErrorMessageChinese } from '@maka/core'; import { permissionModeDescriptions } from './app-shell-copy'; +import { saveComposerDefaults } from './composer-defaults'; type RefBox = { current: T }; type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; @@ -53,6 +54,8 @@ export function createAppShellSessionSettingsActions(deps: { const sessionId = activeIdRef.current; if (!sessionId) { setPendingNewChatPermissionMode(mode); + // Keep the global default in sync so the next "新任务" inherits it. + saveComposerDefaults({ permissionMode: mode }); return; } if (pendingPermissionModeChangesRef.current.has(sessionId)) return; @@ -74,6 +77,8 @@ export function createAppShellSessionSettingsActions(deps: { bypass: '跳过确认', }; if (activeIdRef.current === sessionId) toastApi.success(`已切到 ${labels[mode]}`, permissionModeDescriptions[mode]); + // Sync the global default so a subsequent "新任务" inherits this pick. + saveComposerDefaults({ permissionMode: mode }); await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) { @@ -109,6 +114,8 @@ export function createAppShellSessionSettingsActions(deps: { `${connection?.name ?? next.llmConnectionSlug} · ${next.model}`, ); } + // Sync the global default so a subsequent "新任务" inherits this pick. + saveComposerDefaults({ model: input }); await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) toastApi.error('切换模型失败', generalizedErrorMessageChinese(error, '模型暂时无法切换,请稍后重试。')); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e19c00d107..9a58ec06e1 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -104,6 +104,7 @@ import { useAppShellRefSync, useSessionEventHealthPolling, } from './app-shell-effects'; +import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; type ComposerImportOwner = { sessionId: string | undefined; @@ -197,7 +198,26 @@ export function AppShell() { const [defaultPermissionMode, setDefaultPermissionMode] = useState('ask'); const [skills, setSkills] = useState([]); const [planReminders, setPlanReminders] = useState([]); - const [appInfo, setAppInfo] = useState(null); + // Persisted composer defaults seed the empty-state model, permission mode, + // project path, and recent workspace history so the home view is populated + // before the async `app:info` round-trip completes on mount. + const persistedComposerDefaults = loadComposerDefaults(); + const [pendingNewChatModel, setPendingNewChatModel] = useState<{ llmConnectionSlug: string; model: string } | null>( + persistedComposerDefaults?.model ?? null, + ); + const [pendingNewChatPermissionMode, setPendingNewChatPermissionMode] = useState( + persistedComposerDefaults?.permissionMode ?? null, + ); + const [appInfo, setAppInfo] = useState( + persistedComposerDefaults?.projectPath + ? { projectPath: persistedComposerDefaults.projectPath, projectGit: { isGitRepo: false } } + : null, + ); + const [branchList, setBranchList] = useState<{ branches: string[]; current?: string } | null>(null); + const [branchPending, setBranchPending] = useState(false); + const [recentProjectPaths, setRecentProjectPaths] = useState( + persistedComposerDefaults?.recentProjectPaths ?? [], + ); const [projectPickerPending, setProjectPickerPending] = useState(false); const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); const [paletteOpen, openPalette, closePalette] = useCommandPalette(); @@ -291,8 +311,6 @@ export function AppShell() { // Null = follow the default connection; a pick overrides it (sticky until // changed) and is forwarded to sessions.create in `send()`. Renderer-only — // it never mutates the persisted Settings · 模型 default. - const [pendingNewChatModel, setPendingNewChatModel] = useState<{ llmConnectionSlug: string; model: string } | null>(null); - const [pendingNewChatPermissionMode, setPendingNewChatPermissionMode] = useState(null); // A pick only stays in effect while it is still an offered choice. If the user // later disables/removes that connection or model, fall back to the default so // the home chip never shows — nor sends — a model that no longer exists. @@ -751,15 +769,22 @@ export function AppShell() { const { refreshAppInfo, selectProjectDirectory, + selectRecentProjectDirectory, openProjectFolder, openWorkspaceFolder, openSkillsFolder, + listGitBranches, + checkoutGitBranch, } = createAppShellProjectActions({ projectPickerPendingRef, projectPickerRequestRef, rendererMountedRef, setAppInfo, setProjectPickerPending, + setBranchPending, + setBranchList, + setRecentProjectPaths, + recentProjectPaths, toastApi, }); @@ -813,6 +838,7 @@ export function AppShell() { pendingNewChatPermissionMode, setPendingNewChatPermissionMode, validPendingNewChatModel, + projectPath: appInfo?.projectPath ?? null, }); const { handleTurnFooterAction } = createAppShellTurnActions({ @@ -1040,6 +1066,9 @@ export function AppShell() { setSearchScrollTarget(null); setMessageLoadPending(false); setMessages([]); + // New-task affordances reset to the empty-state composer; move focus + // there so the user can start typing immediately. + window.requestAnimationFrame(() => composerRef.current?.focus()); } function openPlanReminderForm() { @@ -1420,16 +1449,38 @@ export function AppShell() { modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} onModelChange={(input) => setSessionModel(input)} newChatModel={newChatModel} - onPickNewChatModel={(input) => setPendingNewChatModel(input)} + onPickNewChatModel={(input) => { + setPendingNewChatModel(input); + saveComposerDefaults({ model: input }); + }} onOpenModelSettings={() => openSettingsSection('models')} workspacePicker={{ label: appInfo ? basenameFromPath(appInfo.projectPath) : undefined, branch: appInfo?.projectGit.branch, pending: projectPickerPending, + recentWorkspaces: recentProjectPaths, onOpen: () => { void selectProjectDirectory(); }, + onSelect: (path: string) => { + void selectRecentProjectDirectory(path); + }, }} + branchPicker={ + appInfo?.projectGit.isGitRepo + ? { + branch: appInfo.projectGit.branch ?? null, + pending: branchPending, + branches: branchList?.branches ?? [], + onOpen: () => { + void listGitBranches(); + }, + onSelect: (branch: string) => { + void checkoutGitBranch(branch); + }, + } + : undefined + } permissionMode={activeSessionForView?.permissionMode ?? pendingNewChatPermissionMode ?? defaultPermissionMode} permissionModePending={activeId ? pendingPermissionModeBySession[activeId] === true : false} permissionModeDisabledReason={ diff --git a/apps/desktop/src/renderer/composer-defaults.ts b/apps/desktop/src/renderer/composer-defaults.ts new file mode 100644 index 0000000000..fa9da3150d --- /dev/null +++ b/apps/desktop/src/renderer/composer-defaults.ts @@ -0,0 +1,84 @@ +/** + * Global "last selection" defaults for the composer's folder / permission + * mode / model chips. Survives reloads so a freshly created task inherits the + * most recent pick instead of falling back to factory defaults. + * + * Keyed with a `v1` suffix to allow schema migration later. + */ + +import type { PermissionMode } from '@maka/core'; +import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage'; + +const STORAGE_KEY = 'maka-composer-defaults-v1'; + +export const MAX_RECENT_PATHS = 5; + +export interface ComposerDefaults { + projectPath: string | null; + permissionMode: PermissionMode | null; + model: { llmConnectionSlug: string; model: string } | null; + recentProjectPaths: string[]; +} + +const EMPTY: ComposerDefaults = { + projectPath: null, + permissionMode: null, + model: null, + recentProjectPaths: [], +}; + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(isString); +} +function isPermissionMode(value: unknown): value is PermissionMode { + return value === 'explore' || value === 'ask' || value === 'execute' || value === 'bypass'; +} +function isModel(value: unknown): value is { llmConnectionSlug: string; model: string } { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return isString(record.llmConnectionSlug) && isString(record.model); +} + +function parse(raw: string | null): ComposerDefaults | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Record; + return { + projectPath: isString(parsed.projectPath) ? parsed.projectPath : null, + permissionMode: isPermissionMode(parsed.permissionMode) ? parsed.permissionMode : null, + model: isModel(parsed.model) ? parsed.model : null, + recentProjectPaths: isStringArray(parsed.recentProjectPaths) ? parsed.recentProjectPaths.slice(0, MAX_RECENT_PATHS) : [], + }; + } catch { + // Corrupt JSON — treat as absent so callers fall back to defaults. + return null; + } +} + +/** Read the persisted defaults. Returns `null` when storage is empty/invalid. */ +export function loadComposerDefaults(): ComposerDefaults | null { + return parse(safeLocalStorageGet(STORAGE_KEY)); +} + +/** + * Merge-write: reads the current persisted blob, overlays the provided partial, + * and writes back. Fields set to `null` are cleared. Keeps the on-disk shape + * stable even when only one of the three selections changes. + */ +export function saveComposerDefaults(patch: Partial): void { + const current = loadComposerDefaults() ?? EMPTY; + let recentProjectPaths = patch.recentProjectPaths !== undefined ? patch.recentProjectPaths : current.recentProjectPaths; + if (recentProjectPaths.length > MAX_RECENT_PATHS) { + recentProjectPaths = recentProjectPaths.slice(0, MAX_RECENT_PATHS); + } + const next: ComposerDefaults = { + projectPath: patch.projectPath !== undefined ? patch.projectPath : current.projectPath, + permissionMode: patch.permissionMode !== undefined ? patch.permissionMode : current.permissionMode, + model: patch.model !== undefined ? patch.model : current.model, + recentProjectPaths, + }; + safeLocalStorageSet(STORAGE_KEY, JSON.stringify(next)); +} \ No newline at end of file diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index eac34f5f1a..3d88f85bba 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -53,6 +53,7 @@ width: min(var(--maka-chat-measure, 680px), 100%); display: flex; justify-content: flex-start; + gap: var(--space-1); box-sizing: border-box; margin: var(--space-2) auto 0; } @@ -101,6 +102,77 @@ white-space: nowrap; } +.maka-composer-branch-picker { + -webkit-app-region: no-drag; + appearance: none; + min-width: 0; + min-height: 24px; + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-1-5); + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted-foreground); + font: inherit; + font-size: var(--font-size-caption); + line-height: 1.25; + text-align: left; + transition: color var(--duration-quick) var(--ease-out-strong); +} + +.maka-composer-branch-picker:hover { + color: var(--foreground-secondary); + background: oklch(from var(--foreground) l c h / 0.06); +} + +.maka-composer-branch-picker:focus-visible { + outline: none; + border-radius: var(--radius-control); + box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); +} + +.maka-composer-branch-picker svg { + flex: 0 0 auto; +} + +.maka-composer-branch-current { + min-width: 0; + max-width: 140px; + overflow: hidden; + color: var(--muted-foreground); + text-overflow: ellipsis; + white-space: nowrap; +} + +.maka-composer-branch-empty { + padding: var(--space-2) var(--space-2); + color: var(--muted-foreground); + font-size: var(--font-size-caption); + text-align: center; +} + +.maka-composer-branch-check { + margin-left: auto; +} + +/* Workspace directory picker menu — mirrors the branch menu layout. + Shows recently opened directories at the top with a separator before + the "选择其他目录..." option. */ +.maka-composer-workspace-menu { + min-width: 200px; +} +.maka-composer-workspace-menu [data-slot="menu-item"] svg { + flex: 0 0 auto; +} +.maka-composer-workspace-menu [data-slot="menu-item"] span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Composer streaming hint — replaces the Enter/Shift+Enter help text while * Maka is mid-stream. Pulsing dot signals "live", and the Stop button on * the right swaps in as the only primary action so Send isn't ambiguous. */ diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index eb23e8afec..2dc39e4b6f 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -132,11 +132,12 @@ const baseComposerProps: ComposerProps = { modelChoices, permissionMode: 'ask', onPermissionModeChange: noop, - workspacePicker: { - label: 'maka-agent', - branch: 'opencode/storybook-surface-coverage', - onOpen: noop, - }, + workspacePicker: { + label: 'maka-agent', + branch: 'opencode/storybook-surface-coverage', + onOpen: noop, + onSelect: noop, + }, }; function ShellFrame(props: { children: ReactNode }) { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 413b528f32..3dd5108a64 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -113,6 +113,8 @@ export interface SessionSummary { id: string; cwd?: string; name: string; + /** Absolute working directory this session runs in. */ + cwd?: string; isFlagged: boolean; isArchived: boolean; labels: string[]; diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 585a1de5f0..c0f0d82949 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -988,6 +988,7 @@ export function headerToSummary(h: SessionHeader): SessionSummary { id: h.id, cwd: h.cwd, name: h.name === 'New Session' ? 'New Chat' : h.name, + cwd: h.cwd, isFlagged: h.isFlagged, isArchived: h.isArchived, labels: h.labels, diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index bef4e81326..544126d1be 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -483,6 +483,7 @@ function toSummary(header: SessionHeader, messages: StoredMessage[] = []): Sessi id: header.id, cwd: header.cwd, name: normalizeSessionName(header.name), + cwd: header.cwd, isFlagged: header.isFlagged, isArchived: header.isArchived, labels: header.labels, diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 080740bd12..abfe8d4755 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -10,7 +10,7 @@ import { type KeyboardEvent, type ReactNode, } from 'react'; -import { ArrowUp, ChevronDown, FileEdit, FolderOpen, Mic, Plus } from './icons.js'; +import { ArrowUp, Check, ChevronDown, FileEdit, FolderOpen, GitBranch, History, Mic, Plus } from './icons.js'; import { ChatModelSwitcher, ModelChipStatic, NewChatModelPicker } from './chat-model-switcher.js'; import { type UiLocale, detectUiLocale } from './locale-helpers.js'; import { type ChatModelChoice, modelChoiceValue } from './chat-model-helpers.js'; @@ -25,8 +25,8 @@ import { import type { PermissionMode, ProviderType, SessionSummary } from '@maka/core'; import { Button as UiButton, Textarea as UiTextarea } from './ui.js'; import { Kbd } from './primitives/kbd.js'; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from './primitives/menu.js'; import { PERMISSION_MODE_META, PermissionModeMenuPopup } from './permission-mode-menu.js'; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from './primitives/menu.js'; const COMPOSER_MAX_HEIGHT = 240; @@ -139,7 +139,22 @@ export const Composer = forwardRef< label?: string; branch?: string | null; pending?: boolean; + recentWorkspaces?: string[]; onOpen(): void; + onSelect(path: string): void; + }; + /** + * Git branch picker for the workspace row, shown to the right of + * the folder indicator when the workspace is a git repository. + * Clicking the trigger opens a Menu listing local branches; selecting + * one fires `onSelect` to switch branches (handled in the shell). + */ + branchPicker?: { + branch: string | null; + pending?: boolean; + branches: string[]; + onOpen(): void; + onSelect(branch: string): void; }; /** * PR-MOVE-PERMISSION-MODE (WAWQAQ 47fe0d0e + a667cf6c): the @@ -697,7 +712,9 @@ export const Composer = forwardRef< - {props.workspacePicker && ( + {props.workspacePicker && (() => { + const wp = props.workspacePicker!; + return (
{/* PR-COMPOSER-WORKSPACE-PICKER-PRIMITIVE-0 (round 9/30): the workspace picker badge was a raw `
- )} + ); + })()} ); }); + +/** Extract the last path segment from a file system path (win32 / posix). */ +function basenameFromPath(value: string): string { + const trimmed = value.replace(/[\\/]+$/, ''); + const name = trimmed.split(/[\\/]/).filter(Boolean).pop(); + return name || trimmed || '当前项目'; +} diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx index af3445f4d7..e8c46f7e83 100644 --- a/packages/ui/src/icons.tsx +++ b/packages/ui/src/icons.tsx @@ -59,6 +59,7 @@ export { Globe, Grid3X3, HelpCircle, + History, Hourglass, Info, KeyRound, diff --git a/packages/ui/stories/chat-surface.stories.tsx b/packages/ui/stories/chat-surface.stories.tsx index 1b78101775..036078a693 100644 --- a/packages/ui/stories/chat-surface.stories.tsx +++ b/packages/ui/stories/chat-surface.stories.tsx @@ -116,11 +116,12 @@ const baseComposerProps: ComposerProps = { modelChoices, permissionMode: 'ask', onPermissionModeChange: noop, - workspacePicker: { - label: 'maka-agent', - branch: 'codex/storybook-chat-surface', - onOpen: noop, - }, + workspacePicker: { + label: 'maka-agent', + branch: 'codex/storybook-chat-surface', + onOpen: noop, + onSelect: noop, + }, }; function SurfaceFrame(props: { children: ReactNode; narrow?: boolean }) { From db98f72139b243ff39cd21693c5e3e19940f24d0 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:41:36 +0800 Subject: [PATCH 2/4] fix: address PR#511 review issues - P1: Remove permissionMode from composer-defaults persistence. pendingNewChatPermissionMode now starts from null (never seeds from localStorage) to avoid silently inheriting a high-privilege mode across restarts. - P1: Update project-context-badge.test.ts regex to match the current workspacePicker render (wp alias instead of props.workspacePicker). - P1: Replace bare line-height: 1.25 with var(--leading-tight) in composer.css to comply with PR-LEADING-CONVERGE-0 governance. - P2: Add dirty-worktree guard test for checkoutBranch: fakes non-empty git status --porcelain and asserts reason: 'dirty' with no checkout call. --- .../src/main/__tests__/git-branch.test.ts | 20 +++++++++++++++++++ .../__tests__/project-context-badge.test.ts | 8 ++++---- apps/desktop/src/renderer/app-shell.tsx | 16 +++++++++------ .../desktop/src/renderer/composer-defaults.ts | 8 -------- apps/desktop/src/renderer/styles/composer.css | 2 +- 5 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/main/__tests__/git-branch.test.ts b/apps/desktop/src/main/__tests__/git-branch.test.ts index ae6a9e6e7a..9beac294fa 100644 --- a/apps/desktop/src/main/__tests__/git-branch.test.ts +++ b/apps/desktop/src/main/__tests__/git-branch.test.ts @@ -162,4 +162,24 @@ describe('checkoutBranch', () => { assert.equal(result.ok, false); assert.equal(result.reason, 'failed'); }); + + it('refuses checkout when worktree is dirty', async () => { + await withGitRepo(async (root) => { + let gitArgs: readonly string[] = []; + const execFileImpl = fakeExecFile((args) => { + gitArgs = args; + if (args[0] === 'status' && args[1] === '--porcelain') { + return { error: null, stdout: 'M index.ts\n', stderr: '' }; + } + return { error: null, stdout: '', stderr: '' }; + }); + const result = await checkoutBranch(root, 'develop', { execFileImpl }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'dirty'); + assert.equal(result.message, '工作区有未提交的更改,请先提交或暂存。'); + // The only git command that was run was `git status --porcelain`; + // `git checkout` must NOT be called on a dirty worktree. + assert.deepEqual(gitArgs, ['status', '--porcelain']); + }); + }); }); diff --git a/apps/desktop/src/main/__tests__/project-context-badge.test.ts b/apps/desktop/src/main/__tests__/project-context-badge.test.ts index 927f1864a8..bc0fee35d4 100644 --- a/apps/desktop/src/main/__tests__/project-context-badge.test.ts +++ b/apps/desktop/src/main/__tests__/project-context-badge.test.ts @@ -138,14 +138,14 @@ describe('project context workspace picker', () => { assert.match(ui, /className="maka-composer-workspace-picker"/); assert.match(ui, /branch\?: string \| null;/); assert.match(ui, /pending\?: boolean;/); - assert.match(ui, /disabled=\{props\.workspacePicker\.pending === true\}/); - assert.match(ui, /aria-busy=\{props\.workspacePicker\.pending === true \? 'true' : undefined\}/); + assert.match(ui, /disabled=\{wp\.pending === true\}/); + assert.match(ui, /aria-busy=\{wp\.pending === true \? 'true' : undefined\}/); // WAWQAQ msg `28128c9e` (2026-06-20): the "选择工作目录" placeholder // is only rendered when no directory has been selected yet. Once // a label is set, the picker renders `.maka-composer-workspace-current` // alone — no more "选择工作目录 ai ▾" doubled string. - assert.match(ui, /\? \{props\.workspacePicker\.label\}<\/span>[\s\S]*?: 选择工作目录<\/span>/); - assert.match(ui, /当前分支 \$\{props\.workspacePicker\.branch\}/); + assert.match(ui, /\? \{wp\.label\}<\/span>[\s\S]*?: 选择工作目录<\/span>/); + assert.match(ui, /当前分支 \$\{wp\.branch\}/); // Workspace picker must track the shared chat/composer measure token, // not a bespoke hard-coded width, so future measure updates keep the // row aligned with the composer card automatically. diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 9a58ec06e1..456302b427 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -198,16 +198,20 @@ export function AppShell() { const [defaultPermissionMode, setDefaultPermissionMode] = useState('ask'); const [skills, setSkills] = useState([]); const [planReminders, setPlanReminders] = useState([]); - // Persisted composer defaults seed the empty-state model, permission mode, - // project path, and recent workspace history so the home view is populated - // before the async `app:info` round-trip completes on mount. + // Persisted composer defaults seed the empty-state model, project path, and + // recent workspace history so the home view is populated before the async + // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); const [pendingNewChatModel, setPendingNewChatModel] = useState<{ llmConnectionSlug: string; model: string } | null>( persistedComposerDefaults?.model ?? null, ); - const [pendingNewChatPermissionMode, setPendingNewChatPermissionMode] = useState( - persistedComposerDefaults?.permissionMode ?? null, - ); + // Permission mode is renderer-only, scoped to one new-chat decision. + // It must NOT persist across reloads — persisted permission would + // silently inherit a previous session's mode (e.g. auto-edit) after + // restart with no visible signal, which is a safety regression. + // The single authority is main.ts's Settings → 通用 default. See + // session-status-presentation.test.ts for the contract. + const [pendingNewChatPermissionMode, setPendingNewChatPermissionMode] = useState(null); const [appInfo, setAppInfo] = useState( persistedComposerDefaults?.projectPath ? { projectPath: persistedComposerDefaults.projectPath, projectGit: { isGitRepo: false } } diff --git a/apps/desktop/src/renderer/composer-defaults.ts b/apps/desktop/src/renderer/composer-defaults.ts index fa9da3150d..0795f4da79 100644 --- a/apps/desktop/src/renderer/composer-defaults.ts +++ b/apps/desktop/src/renderer/composer-defaults.ts @@ -6,7 +6,6 @@ * Keyed with a `v1` suffix to allow schema migration later. */ -import type { PermissionMode } from '@maka/core'; import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage'; const STORAGE_KEY = 'maka-composer-defaults-v1'; @@ -15,14 +14,12 @@ export const MAX_RECENT_PATHS = 5; export interface ComposerDefaults { projectPath: string | null; - permissionMode: PermissionMode | null; model: { llmConnectionSlug: string; model: string } | null; recentProjectPaths: string[]; } const EMPTY: ComposerDefaults = { projectPath: null, - permissionMode: null, model: null, recentProjectPaths: [], }; @@ -33,9 +30,6 @@ function isString(value: unknown): value is string { function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every(isString); } -function isPermissionMode(value: unknown): value is PermissionMode { - return value === 'explore' || value === 'ask' || value === 'execute' || value === 'bypass'; -} function isModel(value: unknown): value is { llmConnectionSlug: string; model: string } { if (!value || typeof value !== 'object') return false; const record = value as Record; @@ -48,7 +42,6 @@ function parse(raw: string | null): ComposerDefaults | null { const parsed = JSON.parse(raw) as Record; return { projectPath: isString(parsed.projectPath) ? parsed.projectPath : null, - permissionMode: isPermissionMode(parsed.permissionMode) ? parsed.permissionMode : null, model: isModel(parsed.model) ? parsed.model : null, recentProjectPaths: isStringArray(parsed.recentProjectPaths) ? parsed.recentProjectPaths.slice(0, MAX_RECENT_PATHS) : [], }; @@ -76,7 +69,6 @@ export function saveComposerDefaults(patch: Partial): void { } const next: ComposerDefaults = { projectPath: patch.projectPath !== undefined ? patch.projectPath : current.projectPath, - permissionMode: patch.permissionMode !== undefined ? patch.permissionMode : current.permissionMode, model: patch.model !== undefined ? patch.model : current.model, recentProjectPaths, }; diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index df3784025f..6e9039e30d 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -117,7 +117,7 @@ color: var(--muted-foreground); font: inherit; font-size: var(--font-size-caption); - line-height: 1.25; + line-height: var(--leading-tight); text-align: left; transition: color var(--duration-quick) var(--ease-out-strong); } From cf0c5be6157859bdd2091f821be422e0b9bcfab0 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:29:55 +0800 Subject: [PATCH 3/4] fix: address composer workspace review --- ...ser-new-chat-model-picker-contract.test.ts | 9 ++- .../src/main/__tests__/git-branch.test.ts | 58 +++++++++++++++++-- .../__tests__/project-context-badge.test.ts | 23 ++++++++ .../__tests__/workspace-instructions.test.ts | 9 ++- apps/desktop/src/main/git-branch.ts | 8 ++- apps/desktop/src/main/main.ts | 38 +++++++----- .../app-shell-session-settings-actions.ts | 4 -- apps/desktop/src/renderer/styles/composer.css | 2 +- 8 files changed, 118 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts b/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts index 012cfdc629..229af60a33 100644 --- a/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts +++ b/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts @@ -120,8 +120,13 @@ describe('home composer new-chat model picker', () => { assert.match( renderer, - /const \[pendingNewChatPermissionMode, setPendingNewChatPermissionMode\] = useState\([\s\S]*persistedComposerDefaults\?\.permissionMode \?\? null,[\s\S]*\)/, - 'AppShell must seed the picked empty-state permission mode from persisted composer defaults', + /const \[pendingNewChatPermissionMode, setPendingNewChatPermissionMode\] = useState\(null\)/, + 'AppShell must keep the no-session permission pick renderer-only and start it from null', + ); + assert.doesNotMatch( + renderer, + /saveComposerDefaults\(\{\s*permissionMode:/, + 'permission mode must not be persisted into composer defaults', ); assert.match( renderer, diff --git a/apps/desktop/src/main/__tests__/git-branch.test.ts b/apps/desktop/src/main/__tests__/git-branch.test.ts index 9beac294fa..e2e51b5945 100644 --- a/apps/desktop/src/main/__tests__/git-branch.test.ts +++ b/apps/desktop/src/main/__tests__/git-branch.test.ts @@ -139,14 +139,17 @@ describe('listLocalBranches', () => { describe('checkoutBranch', () => { it('switches to a valid branch and returns the new branch name', async () => { await withGitRepo(async (root) => { - let gitArgs: readonly string[] = []; + const gitCalls: string[][] = []; const execFileImpl = fakeExecFile((args) => { - gitArgs = args; + gitCalls.push([...args]); return { error: null, stdout: '', stderr: '' }; }); const result = await checkoutBranch(root, 'develop', { execFileImpl }); assert.ok(result.ok); - assert.deepEqual(gitArgs, ['checkout', 'develop']); + assert.deepEqual(gitCalls, [ + ['status', '--porcelain'], + ['checkout', 'develop'], + ]); }); }); @@ -165,9 +168,9 @@ describe('checkoutBranch', () => { it('refuses checkout when worktree is dirty', async () => { await withGitRepo(async (root) => { - let gitArgs: readonly string[] = []; + const gitCalls: string[][] = []; const execFileImpl = fakeExecFile((args) => { - gitArgs = args; + gitCalls.push([...args]); if (args[0] === 'status' && args[1] === '--porcelain') { return { error: null, stdout: 'M index.ts\n', stderr: '' }; } @@ -179,7 +182,50 @@ describe('checkoutBranch', () => { assert.equal(result.message, '工作区有未提交的更改,请先提交或暂存。'); // The only git command that was run was `git status --porcelain`; // `git checkout` must NOT be called on a dirty worktree. - assert.deepEqual(gitArgs, ['status', '--porcelain']); + assert.deepEqual(gitCalls, [['status', '--porcelain']]); + }); + }); + + it('fails closed when dirty-check status command fails', async () => { + await withGitRepo(async (root) => { + const gitCalls: string[][] = []; + const execFileImpl = fakeExecFile((args) => { + gitCalls.push([...args]); + if (args[0] === 'status' && args[1] === '--porcelain') { + return { + error: errnoException('EACCES', 'status failed'), + stdout: '', + stderr: 'fatal: cannot inspect worktree', + }; + } + return { error: null, stdout: '', stderr: '' }; + }); + const result = await checkoutBranch(root, 'develop', { execFileImpl }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'failed'); + assert.equal(result.message, 'fatal: cannot inspect worktree'); + assert.deepEqual(gitCalls, [['status', '--porcelain']]); + }); + }); + + it('fails closed when dirty-check status command times out', async () => { + await withGitRepo(async (root) => { + const gitCalls: string[][] = []; + const execFileImpl = fakeExecFile((args) => { + gitCalls.push([...args]); + if (args[0] === 'status' && args[1] === '--porcelain') { + return { + error: Object.assign(errnoException('ETIMEDOUT', 'timeout'), { killed: true }), + stdout: '', + stderr: '', + }; + } + return { error: null, stdout: '', stderr: '' }; + }); + const result = await checkoutBranch(root, 'develop', { execFileImpl }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'timeout'); + assert.deepEqual(gitCalls, [['status', '--porcelain']]); }); }); }); diff --git a/apps/desktop/src/main/__tests__/project-context-badge.test.ts b/apps/desktop/src/main/__tests__/project-context-badge.test.ts index bc0fee35d4..be3a0abeff 100644 --- a/apps/desktop/src/main/__tests__/project-context-badge.test.ts +++ b/apps/desktop/src/main/__tests__/project-context-badge.test.ts @@ -61,6 +61,10 @@ describe('project context workspace picker', () => { assert.match(main, /resolveProjectRoot\(\[process\.cwd\(\), app\.getAppPath\(\)\]\)/); assert.match(main, /projectGit:\s*await resolveProjectGitInfo\(projectPath\)/); + assert.match(main, /function registerIpc\(\): void/); + assert.match(main, /const persistedProjectRootPromise = loadPersistedProjectRoot\(\)/); + assert.doesNotMatch(main, /async function registerIpc\(\): Promise/); + assert.doesNotMatch(main, /void registerIpc\(\);/); assert.match(preload, /projectPath:\s*string;/); assert.match(preload, /projectGit:\s*\{ isGitRepo: boolean; branch\?: string \};/); assert.match(globalTypes, /projectPath:\s*string;/); @@ -82,6 +86,16 @@ describe('project context workspace picker', () => { assert.match(main, /const projectPath = await resolveProjectRoot\(\[selectedPath\]\)/); assert.match(main, /selectedProjectRoot = projectPath;/); assert.match(main, /projectGit:\s*await resolveProjectGitInfo\(projectPath\)/); + assert.match( + main, + /async function loadPersistedProjectRoot\(\): Promise \{[\s\S]*await stat\(parsed\.projectPath\)[\s\S]*return await resolveProjectRoot\(\[parsed\.projectPath\]\)/, + 'restored last-project-path must be validated before it becomes currentProjectRoot', + ); + assert.match( + main, + /if \(selectedProjectRoot\) return selectedProjectRoot;[\s\S]*const persistedProjectRoot = await persistedProjectRootPromise;[\s\S]*if \(persistedProjectRoot\) \{[\s\S]*selectedProjectRoot = persistedProjectRoot;[\s\S]*return persistedProjectRoot;/, + 'currentProjectRoot must await the validated persisted project before falling back', + ); assert.match(preload, /selectProjectDirectory\(\): Promise { assert.doesNotMatch(workspacePickerBlock, /openProjectFolder\(\)|openWorkspaceFolder\(\)|openPath\(/); }); + it('resolves workspace instruction files under the selected project root', async () => { + const main = await readRepo('apps/desktop/src/main/main.ts'); + + assert.match(main, /ipcMain\.handle\('workspaceInstructions:getState', async \(\) => getWorkspaceInstructionsState\(await currentProjectRoot\(\)\)\)/); + assert.match(main, /resolveWorkspaceInstructionFileForOpen\(await currentProjectRoot\(\), typeof file === 'string' \? file : ''\)/); + assert.match(main, /createWorkspaceInstructionFile\(await currentProjectRoot\(\), typeof file === 'string' \? file : ''\)/); + assert.doesNotMatch(main, /workspaceInstructions:getState', \(\) => getWorkspaceInstructionsState\(process\.cwd\(\)\)/); + }); + it('opens project directory by allowlisted key, not renderer-supplied path', async () => { const main = await readRepo('apps/desktop/src/main/main.ts'); const guard = await readRepo('apps/desktop/src/main/open-path-guard.ts'); diff --git a/apps/desktop/src/main/__tests__/workspace-instructions.test.ts b/apps/desktop/src/main/__tests__/workspace-instructions.test.ts index bc91b8edba..fdfa972a4a 100644 --- a/apps/desktop/src/main/__tests__/workspace-instructions.test.ts +++ b/apps/desktop/src/main/__tests__/workspace-instructions.test.ts @@ -139,13 +139,18 @@ describe('workspace instructions prompt fragment', () => { }); }); - it('wires create action through main, preload, and Settings UI without arbitrary paths', async () => { + it('wires instruction actions through the selected project root without arbitrary paths', async () => { const main = await readFile(join(process.cwd(), 'src/main/main.ts'), 'utf8'); const preload = await readFile(join(process.cwd(), 'src/preload/preload.ts'), 'utf8'); const settings = await readSettingsCombinedSource(); + assert.match(main, /workspaceInstructions:getState/); + assert.match(main, /getWorkspaceInstructionsState\(await currentProjectRoot\(\)\)/); + assert.match(main, /workspaceInstructions:openFile/); + assert.match(main, /resolveWorkspaceInstructionFileForOpen\(await currentProjectRoot\(\), typeof file === 'string' \? file : ''\)/); assert.match(main, /workspaceInstructions:createFile/); - assert.match(main, /createWorkspaceInstructionFile\(process\.cwd\(\), typeof file === 'string' \? file : ''\)/); + assert.match(main, /createWorkspaceInstructionFile\(await currentProjectRoot\(\), typeof file === 'string' \? file : ''\)/); + assert.doesNotMatch(main, /workspaceInstructions:[\s\S]*InstructionFile[^(]*\(process\.cwd\(\)/); assert.match(preload, /createFile\(file: string\)/); assert.match(settings, /file\.status === 'missing'/); assert.match(settings, /createWorkspaceInstructionFile\(file\.file\)/); diff --git a/apps/desktop/src/main/git-branch.ts b/apps/desktop/src/main/git-branch.ts index 02e9352a35..5f86ebaa57 100644 --- a/apps/desktop/src/main/git-branch.ts +++ b/apps/desktop/src/main/git-branch.ts @@ -114,12 +114,16 @@ export async function checkoutBranch( // Guard against dirty worktree: refuse checkout when there are // uncommitted changes the user could lose or mix up. - const { stdout: statusOutput } = await runGit({ + const { stdout: statusOutput, stderr: statusErrorOutput, error: statusError } = await runGit({ cwd: projectRoot, args: ['status', '--porcelain'], timeoutMs: LIST_TIMEOUT_MS, execFileImpl: input.execFileImpl, }); + if (statusError) { + const reason = classifyError(statusError); + return { ok: false, reason, message: statusErrorOutput.trim() || statusError.message }; + } if (statusOutput.trim()) { return { ok: false, reason: 'dirty', message: '工作区有未提交的更改,请先提交或暂存。' }; } @@ -139,4 +143,4 @@ export async function checkoutBranch( // conditions where another process moved HEAD between checkout and now). const after = await resolveProjectGitInfo(projectRoot); return { ok: true, branch: after.branch ?? branch }; -} \ No newline at end of file +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 84adf6a8e1..c8bd0c6d08 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -790,24 +790,25 @@ function proxyTestFailureMessage(result: TestProxyResult): string { return '代理不可达,请检查代理服务器地址、端口或认证信息。'; } -async function registerIpc(): Promise { +function registerIpc(): void { const LAST_PROJECT_PATH_FILE = join(workspaceRoot, 'last-project-path.json'); let selectedProjectRoot: string | null = null; - // Initialize from persisted state BEFORE any IPC handler runs, so - // app:info / app:listGitBranches / app:checkoutGitBranch always see - // the last-selected project path on reload (no race between the async - // file read and the first renderer request). - try { - const raw = await readFile(LAST_PROJECT_PATH_FILE, 'utf8'); - const parsed = JSON.parse(raw) as Record; - if (typeof parsed.projectPath === 'string' && parsed.projectPath) { - selectedProjectRoot = parsed.projectPath; + async function loadPersistedProjectRoot(): Promise { + try { + const raw = await readFile(LAST_PROJECT_PATH_FILE, 'utf8'); + const parsed = JSON.parse(raw) as Record; + if (typeof parsed.projectPath === 'string' && parsed.projectPath) { + await stat(parsed.projectPath); + return await resolveProjectRoot([parsed.projectPath]); + } + } catch { + // File missing, invalid, or points at a deleted directory. } - } catch { - // File missing or invalid — first run. + return null; } + const persistedProjectRootPromise = loadPersistedProjectRoot(); async function saveLastProjectPath(projectPath: string): Promise { try { @@ -819,6 +820,11 @@ async function registerIpc(): Promise { async function currentProjectRoot(): Promise { if (selectedProjectRoot) return selectedProjectRoot; + const persistedProjectRoot = await persistedProjectRootPromise; + if (persistedProjectRoot) { + selectedProjectRoot = persistedProjectRoot; + return persistedProjectRoot; + } return resolveProjectRoot([process.cwd(), app.getAppPath()]); } @@ -932,11 +938,11 @@ async function registerIpc(): Promise { }, ); registerMemoryIpc({ localMemory }); - ipcMain.handle('workspaceInstructions:getState', () => getWorkspaceInstructionsState(process.cwd())); + ipcMain.handle('workspaceInstructions:getState', async () => getWorkspaceInstructionsState(await currentProjectRoot())); ipcMain.handle( 'workspaceInstructions:openFile', async (_event, file: unknown): Promise<{ ok: true } | { ok: false; message: string }> => { - const resolved = await resolveWorkspaceInstructionFileForOpen(process.cwd(), typeof file === 'string' ? file : ''); + const resolved = await resolveWorkspaceInstructionFileForOpen(await currentProjectRoot(), typeof file === 'string' ? file : ''); if (!resolved.ok) return { ok: false, message: workspaceInstructionOpenFailureCopy(resolved.reason) }; const error = await shell.openPath(resolved.path); return error ? { ok: false, message: workspaceInstructionOpenFailureCopy('open-failed') } : { ok: true }; @@ -945,7 +951,7 @@ async function registerIpc(): Promise { ipcMain.handle( 'workspaceInstructions:createFile', async (_event, file: unknown): Promise<{ ok: true } | { ok: false; message: string }> => { - const created = await createWorkspaceInstructionFile(process.cwd(), typeof file === 'string' ? file : ''); + const created = await createWorkspaceInstructionFile(await currentProjectRoot(), typeof file === 'string' ? file : ''); if (!created.ok) return { ok: false, message: workspaceInstructionCreateFailureCopy(created.reason) }; return { ok: true }; }, @@ -1762,7 +1768,7 @@ async function ensureBootstrapConnection(): Promise { } } -void registerIpc(); +registerIpc(); app.whenReady().then(async () => { // PR-GRAY-CARD-LIFT-0 (WAWQAQ msg `0eb99429` 2026-06-20): set the diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 3b54515e1a..270a077547 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -54,8 +54,6 @@ export function createAppShellSessionSettingsActions(deps: { const sessionId = activeIdRef.current; if (!sessionId) { setPendingNewChatPermissionMode(mode); - // Keep the global default in sync so the next "新任务" inherits it. - saveComposerDefaults({ permissionMode: mode }); return; } if (pendingPermissionModeChangesRef.current.has(sessionId)) return; @@ -77,8 +75,6 @@ export function createAppShellSessionSettingsActions(deps: { bypass: '跳过确认', }; if (activeIdRef.current === sessionId) toastApi.success(`已切到 ${labels[mode]}`, permissionModeDescriptions[mode]); - // Sync the global default so a subsequent "新任务" inherits this pick. - saveComposerDefaults({ permissionMode: mode }); await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) { diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 6e9039e30d..c1c8d9806f 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -124,7 +124,7 @@ .maka-composer-branch-picker:hover { color: var(--foreground-secondary); - background: oklch(from var(--foreground) l c h / 0.06); + background: var(--state-hover-bg); } .maka-composer-branch-picker:focus-visible { From b9a43e50687f14733479828d963990b1c687f8f1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 21:37:53 +0800 Subject: [PATCH 4/4] fix(desktop): bot incoming sessions use the current project root Bot-created sessions used process.cwd(), so a bot message arriving while the user had project A selected could start the session in the app launch directory instead. Rename the BotIncomingMainServiceDeps.cwd() sync string to getCurrentProjectRoot(): Promise, await it at the createSession call site, and wire main.ts to the current project root resolver. The resolver is defined inside registerIpc(), so a module-level provider is reassigned from registerIpc once the resolver exists; the launch directory remains the safe fallback until then. Unifying project-root resolution across main is tracked as a follow-up. --- .../bot-incoming-project-cwd.test.ts | 67 +++++++++++++++++++ apps/desktop/src/main/bot-incoming-main.ts | 4 +- apps/desktop/src/main/main.ts | 8 ++- 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/bot-incoming-project-cwd.test.ts diff --git a/apps/desktop/src/main/__tests__/bot-incoming-project-cwd.test.ts b/apps/desktop/src/main/__tests__/bot-incoming-project-cwd.test.ts new file mode 100644 index 0000000000..23412abb4f --- /dev/null +++ b/apps/desktop/src/main/__tests__/bot-incoming-project-cwd.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { BotIncomingMessage, BotRegistry, SessionManager } from '@maka/runtime'; +import { createBotIncomingMainService } from '../bot-incoming-main.js'; + +describe('bot incoming new-session cwd', () => { + it('creates the bot session with the current project root, not process.cwd()', async () => { + let capturedCwd: unknown = undefined; + let resolveCreated: () => void = () => {}; + const created = new Promise((resolve) => { + resolveCreated = resolve; + }); + const service = createBotIncomingMainService({ + // createSession captures the cwd it was given, signals the test, then + // throws to short-circuit before the streaming / typing path runs. + runtime: { + async createSession(input: { cwd?: unknown }) { + capturedCwd = input.cwd; + resolveCreated(); + throw new Error('__short_circuit_after_create__'); + }, + } as unknown as SessionManager, + botRegistry: { + async sendMessage() {}, + async sendTypingIndicator() { + return false; + }, + isImplemented() { + return true; + }, + } as unknown as BotRegistry, + getCurrentProjectRoot: async () => '/custom/project/root', + getDefaultConnectionSlug: async () => 'slug', + getReadyConnection: async () => ({ connection: { slug: 'slug' }, model: 'm' }), + readSessionHeader: async () => ({ permissionMode: 'ask' }), + ensureSessionCanSend: async () => {}, + emitSessionsChanged() {}, + sendToRenderer() {}, + isStatusChangingSessionEvent() { + return false; + }, + isTurnStatusChangingSessionEvent() { + return false; + }, + }); + + await service.handleBotIncomingMessage({ + platform: 'telegram', + userId: 'u', + userName: 'U', + chatId: 'c1', + isGroup: false, + text: 'hello', + sourceMessageId: '', + receivedAt: Date.now(), + } as unknown as BotIncomingMessage); + + // handleBotIncomingMessage returns before the queued create runs; wait + // for createSession to actually be invoked (or time out). + await Promise.race([ + created, + new Promise((_, reject) => setTimeout(() => reject(new Error('createSession was not called')), 1000)), + ]); + + assert.equal(capturedCwd, '/custom/project/root'); + }); +}); diff --git a/apps/desktop/src/main/bot-incoming-main.ts b/apps/desktop/src/main/bot-incoming-main.ts index c5b4c65032..a7ef00fcd4 100644 --- a/apps/desktop/src/main/bot-incoming-main.ts +++ b/apps/desktop/src/main/bot-incoming-main.ts @@ -46,7 +46,7 @@ export interface BotIncomingMainService { interface BotIncomingMainServiceDeps { runtime: SessionManager; botRegistry: BotRegistry; - cwd(): string; + getCurrentProjectRoot(): Promise; getDefaultConnectionSlug(): Promise; getReadyConnection( slug: string | null | undefined, @@ -235,7 +235,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps): } const ready = await deps.getReadyConnection(await deps.getDefaultConnectionSlug(), undefined); const summary = await deps.runtime.createSession({ - cwd: deps.cwd(), + cwd: await deps.getCurrentProjectRoot(), backend: 'ai-sdk', llmConnectionSlug: ready.connection.slug, model: ready.model, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a433984ba9..2be80fd019 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -424,6 +424,11 @@ let lookupPricing = buildPricingLookup(); // same readiness during reconnect attempts). const previousBotReadiness = new Map(); let botIncoming: ReturnType; +// botIncoming is wired at module load, before registerIpc() defines the +// current-project-root resolver. registerIpc reassigns this once the resolver +// exists; until then the launch directory is the safe fallback. Unifying +// project-root resolution is tracked as a follow-up. +let resolveCurrentProjectRoot: () => Promise = async () => process.cwd(); const botRegistry = new BotRegistry({ onIncomingMessage: (message) => { // Only log incoming bot messages in dev — production stdout leaking @@ -684,7 +689,7 @@ const dailyReview = createDailyReviewMainService({ botIncoming = createBotIncomingMainService({ runtime, botRegistry, - cwd: () => process.cwd(), + getCurrentProjectRoot: () => resolveCurrentProjectRoot(), getDefaultConnectionSlug: () => connectionStore.getDefault(), getReadyConnection, readSessionHeader: (sessionId) => store.readHeader(sessionId), @@ -839,6 +844,7 @@ function registerIpc(): void { } return resolveProjectRoot([process.cwd(), app.getAppPath()]); } + resolveCurrentProjectRoot = currentProjectRoot; async function resolveExplicitProjectRoot(projectPath: unknown): Promise< | { ok: true; projectPath: string }