diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 7d4c512d2f..1b0d26008c 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -436,6 +436,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< + | { ok: true; projectPath: string; projectGit: { isGitRepo: boolean; branch?: string } } + | { ok: false; reason: 'invalid-path' | 'not-found' } + >; + 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__/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/__tests__/composer-new-chat-model-picker-contract.test.ts b/apps/desktop/src/main/__tests__/composer-new-chat-model-picker-contract.test.ts index 99dc3b3b7f..021d52cf90 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 @@ -99,8 +99,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 @@ -150,7 +150,22 @@ 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', + '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, + /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..e2e51b5945 --- /dev/null +++ b/apps/desktop/src/main/__tests__/git-branch.test.ts @@ -0,0 +1,231 @@ +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) => { + const gitCalls: string[][] = []; + const execFileImpl = fakeExecFile((args) => { + gitCalls.push([...args]); + return { error: null, stdout: '', stderr: '' }; + }); + const result = await checkoutBranch(root, 'develop', { execFileImpl }); + assert.ok(result.ok); + assert.deepEqual(gitCalls, [ + ['status', '--porcelain'], + ['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'); + }); + + it('refuses checkout when worktree is dirty', async () => { + await withGitRepo(async (root) => { + const gitCalls: string[][] = []; + const execFileImpl = fakeExecFile((args) => { + gitCalls.push([...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(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 927f1864a8..0f7bf73427 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;/); @@ -76,12 +80,28 @@ describe('project context workspace picker', () => { assert.match(main, /let selectedProjectRoot: string \| null = null;/); assert.match(main, /if \(selectedProjectRoot\) return selectedProjectRoot;/); + assert.match(main, /async function resolveExplicitProjectRoot\(projectPath: unknown\): 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('defaults new sessions to the main-owned current project root', async () => { + const main = await readRepo('apps/desktop/src/main/main.ts'); + const chatActions = await readRepo('apps/desktop/src/renderer/app-shell-chat-actions.ts'); + + assert.match(main, /const cwd = input\?\.cwd \?\? \(await currentProjectRoot\(\)\)/); + assert.match(main, /handleQuickChatStart\(input, currentProjectRoot\)/); + assert.match(main, /cwd:\s*await getCurrentProjectRoot\(\)/); + assert.doesNotMatch(main, /const cwd = input\?\.cwd \?\? process\.cwd\(\)/); + assert.doesNotMatch(main, /cwd:\s*process\.cwd\(\)/); + assert.doesNotMatch(chatActions, /\.\.\.\(projectPath \? \{ cwd: projectPath \} : \{\}\)/); + }); + + 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'); @@ -138,14 +179,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/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/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/git-branch.ts b/apps/desktop/src/main/git-branch.ts new file mode 100644 index 0000000000..5f86ebaa57 --- /dev/null +++ b/apps/desktop/src/main/git-branch.ts @@ -0,0 +1,146 @@ +import { execFile, type ExecFileException } from 'node:child_process'; +import { resolveProjectGitInfo } from '@maka/runtime'; + +/** + * 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, 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: '工作区有未提交的更改,请先提交或暂存。' }; + } + + 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 }; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 10176fe743..2be80fd019 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, stat, writeFile } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { startConfigFileWatcher, type ConfigFileWatcher } from './config-file-watcher.js'; import { release as osRelease, arch as osArch } from 'node:os'; @@ -112,6 +112,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 '@maka/runtime'; +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 { @@ -423,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 @@ -683,7 +689,7 @@ const dailyReview = createDailyReviewMainService({ botIncoming = createBotIncomingMainService({ runtime, botRegistry, - cwd: () => process.cwd(), + getCurrentProjectRoot: () => resolveCurrentProjectRoot(), getDefaultConnectionSlug: () => connectionStore.getDefault(), getReadyConnection, readSessionHeader: (sessionId) => store.readHeader(sessionId), @@ -802,12 +808,58 @@ 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 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. + } + return null; + } + const persistedProjectRootPromise = loadPersistedProjectRoot(); + + 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. + } + } + async function currentProjectRoot(): Promise { if (selectedProjectRoot) return selectedProjectRoot; + const persistedProjectRoot = await persistedProjectRootPromise; + if (persistedProjectRoot) { + selectedProjectRoot = persistedProjectRoot; + return persistedProjectRoot; + } return resolveProjectRoot([process.cwd(), app.getAppPath()]); } + resolveCurrentProjectRoot = currentProjectRoot; + + async function resolveExplicitProjectRoot(projectPath: unknown): Promise< + | { ok: true; projectPath: string } + | { ok: false; reason: 'invalid-path' | 'not-found' } + > { + if (typeof projectPath !== 'string' || !projectPath) { + return { ok: false, reason: 'invalid-path' }; + } + try { + await stat(projectPath); + } catch { + return { ok: false, reason: 'not-found' }; + } + return { ok: true, projectPath: await resolveProjectRoot([projectPath]) }; + } ipcMain.handle('window:setTitlebarControlsVisible', (event, visible: unknown): void => { mainWindowController.setTitlebarControlsVisible(event.sender, visible); @@ -860,6 +912,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, @@ -867,12 +920,63 @@ 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' } + > => { + const explicitRoot = await resolveExplicitProjectRoot(projectPath); + if (!explicitRoot.ok) return explicitRoot; + const resolved = explicitRoot.projectPath; + selectedProjectRoot = resolved; + void saveLastProjectPath(resolved); + return { + ok: true, + projectPath: resolved, + projectGit: await resolveProjectGitInfo(resolved), + }; + }, + ); + ipcMain.handle( + 'app:resolveProjectGitInfo', + async ( + _event, + projectPath: unknown, + ): Promise< + | { ok: true; projectPath: string; projectGit: Awaited> } + | { ok: false; reason: 'invalid-path' | 'not-found' } + > => { + if (projectPath !== undefined) { + const explicitRoot = await resolveExplicitProjectRoot(projectPath); + if (!explicitRoot.ok) return explicitRoot; + const resolved = explicitRoot.projectPath; + return { ok: true, projectPath: resolved, projectGit: await resolveProjectGitInfo(resolved) }; + } + const resolved = await currentProjectRoot(); + return { ok: true, 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('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 }; @@ -881,7 +985,7 @@ function registerIpc(): void { 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 }; }, @@ -1026,7 +1130,7 @@ function registerIpc(): void { registerPlanReminderIpc({ planReminders, getWorkspacePrivacyContext }); ipcMain.handle('sessions:list', (_event, filter?: SessionListFilter) => runtime.listSessions(filter)); ipcMain.handle('sessions:create', async (_event, input?: Partial) => { - const cwd = input?.cwd ?? process.cwd(); + const cwd = input?.cwd ?? (await currentProjectRoot()); if (input?.backend === 'fake') { if (!canCreateFakeSessionFromRenderer()) { throw new Error('FakeBackend sessions are only available in development.'); @@ -1290,7 +1394,7 @@ function registerIpc(): void { // surfaces (connectionSlug / model) will land in PR110c/d when the // model-picker UI is ready. ipcMain.handle('quickChat:start', async (_event, input: unknown) => { - return handleQuickChatStart(input); + return handleQuickChatStart(input, currentProjectRoot); }); ipcMain.handle('permissions:getSnapshot', () => buildPermissionSnapshot()); @@ -1612,7 +1716,10 @@ function normalizeSupportedSessionThinkingLevel( * `./quick-chat.ts` so it can be unit-tested without spinning up an * Electron app. */ -async function handleQuickChatStart(rawInput: unknown): Promise { +async function handleQuickChatStart( + rawInput: unknown, + getCurrentProjectRoot: () => Promise, +): Promise { return runQuickChatStart(rawInput, { getOnboardingState: async () => (await onboardingService.getSnapshot()).state, createSession: async (input) => { @@ -1631,7 +1738,7 @@ async function handleQuickChatStart(rawInput: unknown): Promise : resolveDefaultPermissionMode(() => settingsStore.get()), ]); return runtime.createSession({ - cwd: process.cwd(), + cwd: await getCurrentProjectRoot(), backend: 'ai-sdk', llmConnectionSlug: ready.connection.slug, model: ready.model, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b576520c98..6beee760a8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -771,6 +771,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< + | { ok: true; projectPath: string; projectGit: { isGitRepo: boolean; branch?: string } } + | { ok: false; reason: 'invalid-path' | 'not-found' } + > { + 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 140ddac066..0fe6eded6d 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -95,6 +95,8 @@ export function createAppShellChatActions(deps: { isNewChatSendSurfaceActive: (owner: ComposerImportOwner) => boolean; markSessionReadLocally: (sessionId: string, readMessages: readonly StoredMessage[]) => void; messageRetryPendingRef: RefBox>; + pendingNewChatPermissionMode: PendingNewChatPermissionMode; + setPendingNewChatPermissionMode: (mode: PendingNewChatPermissionMode) => void; refreshSessions: () => Promise; setActiveId: (sessionId: string | undefined) => void; setMessageLoadErrorBySession: MessageLoadErrorUpdater; @@ -104,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; pendingNewChatThinkingLevel: PendingNewChatThinkingLevel; }): AppShellChatActions { @@ -117,17 +117,17 @@ export function createAppShellChatActions(deps: { isNewChatSendSurfaceActive, markSessionReadLocally, messageRetryPendingRef, + pendingNewChatPermissionMode, refreshSessions, setActiveId, setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, setNavSelection, + setPendingNewChatPermissionMode, showModelSetupToast, toastApi, upsertSessionSummary, - pendingNewChatPermissionMode, - setPendingNewChatPermissionMode, validPendingNewChatModel, pendingNewChatThinkingLevel, } = deps; @@ -179,7 +179,7 @@ export function createAppShellChatActions(deps: { // 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 + // 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 } : {}), 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 5cb9d3237c..12af0f8b1b 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, ThinkingLevel } 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; @@ -110,6 +111,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 211f470c68..852b00ea05 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -106,6 +106,7 @@ import { useAppShellRefSync, useSessionEventHealthPolling, } from './app-shell-effects'; +import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; type ComposerImportOwner = { sessionId: string | undefined; @@ -204,7 +205,30 @@ 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, 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, + ); + // 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 } } + : 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(); @@ -298,8 +322,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); const [pendingNewChatThinkingLevel, setPendingNewChatThinkingLevel] = 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 @@ -784,15 +806,22 @@ export function AppShell({ const { refreshAppInfo, selectProjectDirectory, + selectRecentProjectDirectory, openProjectFolder, openWorkspaceFolder, openSkillsFolder, + listGitBranches, + checkoutGitBranch, } = createAppShellProjectActions({ projectPickerPendingRef, projectPickerRequestRef, rendererMountedRef, setAppInfo, setProjectPickerPending, + setBranchPending, + setBranchList, + setRecentProjectPaths, + recentProjectPaths, toastApi, }); @@ -1074,6 +1103,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() { @@ -1457,7 +1489,10 @@ export function AppShell({ activeThinkingLevel={activeThinkingLevel} onThinkingLevelChange={(level) => setSessionThinkingLevel(level)} newChatModel={newChatModel} - onPickNewChatModel={(input) => setPendingNewChatModel(input)} + onPickNewChatModel={(input) => { + setPendingNewChatModel(input); + saveComposerDefaults({ model: input }); + }} newChatThinkingLevels={newChatThinkingLevels} newChatThinkingLevel={newChatThinkingLevel} onNewChatThinkingLevelChange={(level) => setPendingNewChatThinkingLevel(level ?? null)} @@ -1466,10 +1501,29 @@ export function AppShell({ 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..0795f4da79 --- /dev/null +++ b/apps/desktop/src/renderer/composer-defaults.ts @@ -0,0 +1,76 @@ +/** + * 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 { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage'; + +const STORAGE_KEY = 'maka-composer-defaults-v1'; + +export const MAX_RECENT_PATHS = 5; + +export interface ComposerDefaults { + projectPath: string | null; + model: { llmConnectionSlug: string; model: string } | null; + recentProjectPaths: string[]; +} + +const EMPTY: ComposerDefaults = { + projectPath: 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 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, + 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, + 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 1f352c0134..8d475f0691 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), 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: var(--leading-tight); + text-align: left; + transition: color var(--duration-quick) var(--ease-out-strong); +} + +.maka-composer-branch-picker:hover { + color: var(--foreground-secondary); + background: var(--state-hover-bg); +} + +.maka-composer-branch-picker:focus-visible { + outline: none; + border-radius: var(--radius-control); + box-shadow: 0 0 0 var(--focus-ring-width) 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/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 0b9f88c7b7..3d167d03dc 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'; @@ -27,8 +27,8 @@ import { readGlobalInputHistory, saveGlobalInputHistoryEntry } from './input-his 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; @@ -148,7 +148,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 @@ -745,7 +760,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 }) {