You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/26 17:04:41 UTC

(maka) branch main updated: feat(desktop): let the Git changes panel pick its comparison base branch (#5120)

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new f362f1de79 feat(desktop): let the Git changes panel pick its comparison base branch (#5120)
f362f1de79 is described below

commit f362f1de7943fd109b4a887744ffe140ed7ec7e5
Author: faga <10...@qq.com>
AuthorDate: Sun Sep 27 01:04:37 2026 +0800

    feat(desktop): let the Git changes panel pick its comparison base branch (#5120)
    
    The Git changes panel could only diff against the base branch the backend
    resolved on its own, so stacked work often showed changes inherited from a
    parent branch with no way to pick a different comparison. The panel header
    now reads `current → base` with a searchable picker for the base branch.
    
    Branch options are enumerated as fully qualified refs (`refs/heads/...`,
    `refs/remotes/...`) and carried as `{ label, value }`: the label is what the
    user sees, the value is what gets compared and stored. The main process only
    accepts a requested base that exactly matches an enumerated ref, so short
    names, tags and same-named tag/branch pairs can no longer be confused, and
    `origin/HEAD` stays selectable. Default resolution shares the same priority
    order (the remote's declared default first, then origin/main, origin/master,
    main, master), and the current branch may itself be the base, in which case
    the review shows staged and unstaged changes.
    
    An explicit choice is remembered per Session through a new
    `reviewBaseBranchPreference` Workbar port, backed on Desktop by localStorage;
    leaving it unset keeps following the repository default, and showing the
    resolved default does not pin it. If a saved branch disappears, the panel
    clears the pin and re-reads once with the default. Failed reads now still
    return the branch list, so the user can recover from errors such as unrelated
    histories by picking another base, and a branch diff that overflows the child
    process output buffer is shown truncated instead of failing the whole review.
    
    Compatibility: `GitReviewSnapshot.baseBranchOptions` changes from `string[]`
    to `{ label, value }[]`, and failure results may carry `branches`. No data
    migration; nothing was persisted before this change.
    
    Fixes #5119
    
    Generated-by: Pi (DeepSeek V4.1 Flash)
    Generated-by: OpenAI Codex
    Generated-by: Maka
    Co-Authored-By: Claude <no...@anthropic.com>
---
 CHANGELOG.md                                       |   3 +
 .../src/main/__tests__/git-review-main.test.ts     | 166 ++++++++++++-
 .../review-base-branch-preferences.test.ts         | 101 ++++++++
 .../__tests__/session-review-base-branch.test.ts   |  63 +++++
 .../session-review-panel-recovery.test.ts          | 275 +++++++++++++++++++++
 apps/desktop/src/main/git-review-main.ts           | 119 ++++++---
 .../src/renderer/features/workbar/README.md        |  11 +
 .../desktop/src/renderer/features/workbar/ports.ts |   9 +
 .../src/renderer/features/workbar/testing.ts       |  10 +
 .../review/session-review-base-branch-picker.tsx   |  82 ++++++
 .../workbar/tools/review/session-review-panel.tsx  | 108 +++++++-
 .../src/renderer/locales/conversation-copy.ts      |   8 +-
 .../renderer/platform/desktop/browser-storage.ts   |  34 +++
 .../platform/desktop/create-workbar-services.ts    |   2 +
 .../desktop/review-base-branch-preferences.ts      |  50 ++++
 .../desktop/src/renderer/styles/workbar/review.css |  44 ++++
 apps/desktop/stories/session-workbar.stories.tsx   |  49 +++-
 docs/astryx-surface-file-inventory.md              |   1 +
 docs/astryx-surface-file-inventory.paths           |   1 +
 packages/core/src/git-review.ts                    |  17 +-
 packages/ui/src/astryx-copy.ts                     |  18 +-
 packages/ui/src/astryx-i18n.tsx                    |   2 +
 22 files changed, 1112 insertions(+), 61 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2adde427ff..298d272dbe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,9 @@
   offer an action to open the most recently imported task or create another
   independent import. An uncertain result remains visible as a warning while
   still allowing a user-initiated import again in both Desktop and TUI.
+- The Git changes panel can pick which branch it compares against — searchable,
+  per Session — instead of only the branch the backend resolves. The choice
+  persists, and falls back to the resolved branch when the pinned one disappears.
 - Added `/transcript` to browse long TUI sessions without depending on terminal
   scrollback, with line, page, and first/last navigation.
 
diff --git a/apps/desktop/src/main/__tests__/git-review-main.test.ts b/apps/desktop/src/main/__tests__/git-review-main.test.ts
index b41b7c65b8..c820ada0d7 100644
--- a/apps/desktop/src/main/__tests__/git-review-main.test.ts
+++ b/apps/desktop/src/main/__tests__/git-review-main.test.ts
@@ -51,11 +51,11 @@ describe('Git Review snapshot authority', () => {
     const branch = await readGitReview(root, 'branch');
     assert.equal(branch.ok, true);
     if (!branch.ok) return;
-    assert.equal(branch.snapshot.baseBranch, 'main');
+    assert.equal(branch.snapshot.baseBranch, 'refs/heads/main');
     assert.equal(branch.snapshot.currentBranch, 'feature/review');
     assert.deepEqual(branch.snapshot.baseBranchOptions, [
-      'feature/review',
-      'main',
+      { label: 'main', value: 'refs/heads/main' },
+      { label: 'feature/review', value: 'refs/heads/feature/review' },
     ]);
     assert.deepEqual(
       branch.snapshot.files.map((file) => file.path).sort(),
@@ -67,11 +67,11 @@ describe('Git Review snapshot authority', () => {
       root,
       'branch',
       undefined,
-      'feature/review',
+      'refs/heads/feature/review',
     );
     assert.equal(currentBranchOnly.ok, true);
     if (currentBranchOnly.ok) {
-      assert.equal(currentBranchOnly.snapshot.baseBranch, 'feature/review');
+      assert.equal(currentBranchOnly.snapshot.baseBranch, 'refs/heads/feature/review');
       assert.equal(
         currentBranchOnly.snapshot.files.some((file) => file.path === 'feature.txt'),
         false,
@@ -79,7 +79,10 @@ describe('Git Review snapshot authority', () => {
     }
     assert.deepEqual(
       await readGitReview(root, 'branch', undefined, 'missing-branch'),
-      { ok: false, reason: 'invalid_base_branch' },
+      { ok: false, reason: 'invalid_base_branch', branches: {
+        currentBranch: branch.snapshot.currentBranch,
+        baseBranchOptions: branch.snapshot.baseBranchOptions,
+      } },
     );
 
     const unstaged = await readGitReview(root, 'unstaged');
@@ -99,6 +102,155 @@ describe('Git Review snapshot authority', () => {
     );
   });
 
+  it('lists the remote default branch before the branches it resolves from', async () => {
+    const origin = await repository();
+    await git(origin, 'branch', 'release/0.1');
+    const root = await temporaryRoot();
+    await git(root, 'clone', origin, '.');
+
+    const result = await readGitReview(root, 'branch');
+    assert.equal(result.ok, true);
+    if (!result.ok) return;
+    assert.equal(result.snapshot.baseBranch, 'refs/remotes/origin/main');
+    assert.deepEqual(result.snapshot.baseBranchOptions, [
+      { label: 'origin/HEAD', value: 'refs/remotes/origin/HEAD' },
+      { label: 'origin/main', value: 'refs/remotes/origin/main' },
+      { label: 'main', value: 'refs/heads/main' },
+      { label: 'origin/release/0.1', value: 'refs/remotes/origin/release/0.1' },
+    ]);
+  });
+
+  it('compares the branch rather than a same-named tag, using a qualified ref', async () => {
+    const root = await repository();
+    await git(root, 'tag', 'release');
+    await git(root, 'checkout', '-b', 'release');
+    await writeFile(join(root, 'release.txt'), 'release\n', 'utf8');
+    await git(root, 'add', '.');
+    await git(root, 'commit', '-m', 'release');
+    await git(root, 'checkout', '-b', 'feature');
+    await writeFile(join(root, 'feature.txt'), 'feature\n', 'utf8');
+    await git(root, 'add', '.');
+    await git(root, 'commit', '-m', 'feature');
+
+    const result = await readGitReview(root, 'branch', undefined, 'refs/heads/release');
+    assert.equal(result.ok, true);
+    if (!result.ok) return;
+    assert.equal(result.snapshot.baseBranch, 'refs/heads/release');
+    assert.deepEqual(result.snapshot.files.map((file) => file.path), ['feature.txt']);
+    assert.ok(result.snapshot.baseBranchOptions.every((option) =>
+      option.value.startsWith('refs/heads/') || option.value.startsWith('refs/remotes/')));
+    assert.deepEqual(await readGitReview(root, 'branch', undefined, 'refs/tags/release'),
+      { ok: false, reason: 'invalid_base_branch', branches: { currentBranch: 'feature', baseBranchOptions: [
+        { label: 'main', value: 'refs/heads/main' },
+        { label: 'feature', value: 'refs/heads/feature' },
+        { label: 'release', value: 'refs/heads/release' },
+      ] } });
+    await git(root, 'tag', 'tag-only');
+    assert.deepEqual(await readGitReview(root, 'branch', undefined, 'tag-only'),
+      { ok: false, reason: 'invalid_base_branch', branches: { currentBranch: 'feature', baseBranchOptions: [
+        { label: 'main', value: 'refs/heads/main' },
+        { label: 'feature', value: 'refs/heads/feature' },
+        { label: 'release', value: 'refs/heads/release' },
+      ] } });
+  });
+
+  it('keeps local and remote refs with the same label distinct and rejects unqualified names', async () => {
+    const root = await repository();
+    await git(root, 'update-ref', 'refs/remotes/origin/release', 'HEAD');
+    await git(root, 'checkout', '-b', 'origin/release');
+    await writeFile(join(root, 'local.txt'), 'local\n', 'utf8');
+    await git(root, 'add', '.');
+    await git(root, 'commit', '-m', 'local');
+    const local = await readGitReview(root, 'branch', undefined, 'refs/heads/origin/release');
+    const remote = await readGitReview(root, 'branch', undefined, 'refs/remotes/origin/release');
+    assert.equal(local.ok, true);
+    assert.equal(remote.ok, true);
+    if (!local.ok || !remote.ok) return;
+    assert.equal(local.snapshot.files.length, 0);
+    assert.deepEqual(remote.snapshot.files.map((file) => file.path), ['local.txt']);
+    assert.equal(local.snapshot.baseBranchOptions.filter((option) => option.label === 'origin/release').length, 2);
+    assert.deepEqual(await readGitReview(root, 'branch', undefined, 'origin/release'),
+      { ok: false, reason: 'invalid_base_branch', branches: {
+        currentBranch: local.snapshot.currentBranch,
+        baseBranchOptions: local.snapshot.baseBranchOptions,
+      } });
+  });
+
+  it('resolves the default branch without following a same-named tag', async () => {
+    const root = await repository();
+    await git(root, 'tag', 'main');
+    await writeFile(join(root, 'main.txt'), 'main\n', 'utf8');
+    await git(root, 'add', '.');
+    await git(root, 'commit', '-m', 'advance main');
+    await git(root, 'checkout', '-b', 'feature');
+    const result = await readGitReview(root, 'branch');
+    assert.equal(result.ok, true);
+    if (!result.ok) return;
+    assert.equal(result.snapshot.baseBranch, 'refs/heads/main');
+    assert.equal(result.snapshot.files.length, 0);
+  });
+
+  it('returns branch choices when unrelated history prevents a diff, including on repeated reads', async () => {
+    const root = await repository();
+    await git(root, 'checkout', '--orphan', 'gh-pages');
+    await git(root, 'rm', '-rf', '.');
+    await writeFile(join(root, 'index.html'), 'site\n', 'utf8');
+    await git(root, 'add', '.');
+    await git(root, 'commit', '-m', 'independent site history');
+    await git(root, 'checkout', 'main');
+
+    for (let attempt = 0; attempt < 2; attempt += 1) {
+      const result = await readGitReview(root, 'branch', undefined, 'refs/heads/gh-pages');
+      assert.equal(result.ok, false);
+      if (result.ok) return;
+      assert.equal(result.reason, 'git_failed');
+      assert.deepEqual(result.branches, {
+        currentBranch: 'main',
+        baseBranchOptions: [
+          { label: 'main', value: 'refs/heads/main' },
+          { label: 'gh-pages', value: 'refs/heads/gh-pages' },
+        ],
+      });
+    }
+    const recovered = await readGitReview(root, 'branch', undefined, 'refs/heads/main');
+    assert.equal(recovered.ok, true);
+    if (recovered.ok) assert.equal(recovered.snapshot.files.length, 0);
+  });
+
+  it('degrades a diff that overflows the git buffer to a truncated review', async () => {
+    const root = await repository();
+    await git(root, 'checkout', '-b', 'feature');
+    await writeFile(join(root, 'feature.txt'), 'feature\n', 'utf8');
+    await git(root, 'add', '.');
+    await git(root, 'commit', '-m', 'feature');
+
+    const result = await readGitReview(root, 'branch', async (gitRoot, args) => {
+      if (args.includes('--binary')) {
+        // Node rejects an over-limit child buffer, handing back what it read.
+        throw Object.assign(new Error('stdout maxBuffer length exceeded'), {
+          code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
+          stdout: [
+            'diff --git a/feature.txt b/feature.txt',
+            'new file mode 100644',
+            '--- /dev/null',
+            '+++ b/feature.txt',
+            '@@ -0,0 +1 @@',
+            '+feature',
+            '',
+          ].join('\n'),
+        });
+      }
+      const { stdout } = await execFileAsync('git', ['-C', gitRoot, ...args], {
+        encoding: 'utf8',
+      });
+      return stdout;
+    });
+    assert.equal(result.ok, true);
+    if (!result.ok) return;
+    assert.equal(result.snapshot.truncated, true);
+    assert.deepEqual(result.snapshot.files.map((file) => file.path), ['feature.txt']);
+  });
+
   it('returns an explicit non-repository outcome', async () => {
     const root = await temporaryRoot();
     assert.deepEqual(await readGitReview(root, 'branch'), {
@@ -116,7 +268,7 @@ describe('Git Review snapshot authority', () => {
     const result = await readGitReview(root, 'branch');
     assert.equal(result.ok, true);
     if (!result.ok) return;
-    assert.equal(result.snapshot.baseBranch, null);
+    assert.equal(result.snapshot.baseBranch, 'refs/heads/main');
     assert.deepEqual(
       result.snapshot.files.map((file) => file.path).sort(),
       ['base.txt', 'staged.txt'],
diff --git a/apps/desktop/src/main/__tests__/review-base-branch-preferences.test.ts b/apps/desktop/src/main/__tests__/review-base-branch-preferences.test.ts
new file mode 100644
index 0000000000..e3decda7fc
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/review-base-branch-preferences.test.ts
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { afterEach, beforeEach, describe, it } from 'node:test';
+import {
+  createReviewBaseBranchPreferences,
+  REVIEW_BASE_BRANCH_STORAGE_KEY,
+} from '../../renderer/platform/desktop/review-base-branch-preferences.js';
+
+function memoryStorage() {
+  const items = new Map<string, string>();
+  return {
+    getItem: (key: string) => items.get(key) ?? null,
+    setItem: (key: string, value: string) => { items.set(key, value); },
+  };
+}
+
+describe('Desktop review base branch preferences', () => {
+  let storage: ReturnType<typeof memoryStorage>;
+  let originalStorage: PropertyDescriptor | undefined;
+
+  beforeEach(() => {
+    originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
+    storage = memoryStorage();
+    Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
+  });
+
+  afterEach(() => {
+    if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage);
+    else Reflect.deleteProperty(globalThis, 'localStorage');
+  });
+
+  it('persists explicit refs across adapter instances without pinning new Sessions', () => {
+    const preferences = createReviewBaseBranchPreferences();
+    preferences.write('session-a', 'refs/remotes/origin/develop');
+    preferences.write('session-b', 'refs/heads/main');
+    const reopened = createReviewBaseBranchPreferences();
+    assert.equal(reopened.read('session-a'), 'refs/remotes/origin/develop');
+    assert.equal(reopened.read('session-b'), 'refs/heads/main');
+    assert.equal(reopened.read('new-session'), null);
+    reopened.write('session-a', null);
+    assert.equal(preferences.read('session-a'), null);
+    assert.equal(preferences.read('session-b'), 'refs/heads/main');
+  });
+
+  it('ignores malformed records and recovers on the next explicit choice', () => {
+    const preferences = createReviewBaseBranchPreferences();
+    for (const invalid of ['{ not json', 'null', '[]', '3']) {
+      storage.setItem(REVIEW_BASE_BRANCH_STORAGE_KEY, invalid);
+      assert.equal(preferences.read('session'), null);
+      preferences.write('session', 'refs/heads/main');
+      assert.equal(preferences.read('session'), 'refs/heads/main');
+    }
+    storage.setItem(REVIEW_BASE_BRANCH_STORAGE_KEY, JSON.stringify({
+      empty: '', invalid: 7, valid: 'refs/heads/main',
+    }));
+    assert.equal(preferences.read('empty'), null);
+    assert.equal(preferences.read('invalid'), null);
+    assert.equal(preferences.read('valid'), 'refs/heads/main');
+    preferences.write('another', 'refs/heads/develop');
+    assert.deepEqual(JSON.parse(storage.getItem(REVIEW_BASE_BRANCH_STORAGE_KEY)!), {
+      valid: 'refs/heads/main', another: 'refs/heads/develop',
+    });
+  });
+
+  it('handles a restricted localStorage getter lazily', () => {
+    Object.defineProperty(globalThis, 'localStorage', {
+      configurable: true,
+      get() { throw new Error('Storage access denied'); },
+    });
+    const preferences = createReviewBaseBranchPreferences();
+    assert.equal(preferences.read('session'), null);
+    assert.doesNotThrow(() => preferences.write('session', 'refs/heads/main'));
+    assert.doesNotThrow(() => preferences.write('session', null));
+  });
+
+  it('handles read failures and quota errors without breaking comparisons', () => {
+    storage.getItem = () => { throw new Error('Read denied'); };
+    storage.setItem = () => { throw new Error('Quota exceeded'); };
+    const preferences = createReviewBaseBranchPreferences();
+    assert.equal(preferences.read('session'), null);
+    assert.doesNotThrow(() => preferences.write('session', 'refs/heads/main'));
+  });
+});
diff --git a/apps/desktop/src/main/__tests__/session-review-base-branch.test.ts b/apps/desktop/src/main/__tests__/session-review-base-branch.test.ts
new file mode 100644
index 0000000000..82fd8ddbec
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/session-review-base-branch.test.ts
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+  SessionReviewBaseBranchPicker,
+} from '../../renderer/features/workbar/testing.js';
+
+const AUTO_SENTINEL = 'AUTO_SENTINEL';
+
+function renderPicker(baseBranch: string | null) {
+  return renderToStaticMarkup(
+    createElement(SessionReviewBaseBranchPicker, {
+      baseBranch,
+      baseBranchOptions: [
+        { label: 'main', value: 'refs/heads/main' },
+        { label: 'origin/develop', value: 'refs/remotes/origin/develop' },
+      ],
+      label: AUTO_SENTINEL,
+      onSelect: () => undefined,
+    }),
+  );
+}
+
+/** The visible trigger only: `label` is required by Selector and always lands
+ * in the markup as a visually hidden element, sentinel and all. */
+function renderTrigger(baseBranch: string | null) {
+  const markup = renderPicker(baseBranch);
+  const start = markup.indexOf('<button');
+  return markup.slice(start, markup.indexOf('</button>', start));
+}
+
+describe('session review base branch', () => {
+  it('shows the compared branch instead of an auto pseudo-entry', () => {
+    const trigger = renderTrigger('refs/remotes/origin/develop');
+    assert.match(trigger, />origin\/develop</);
+    assert.doesNotMatch(trigger, /refs\/remotes/);
+    assert.doesNotMatch(trigger, new RegExp(AUTO_SENTINEL));
+  });
+
+  it('falls back to the plain label while nothing is pinned', () => {
+    assert.match(renderTrigger(null), new RegExp(AUTO_SENTINEL));
+  });
+});
diff --git a/apps/desktop/src/main/__tests__/session-review-panel-recovery.test.ts b/apps/desktop/src/main/__tests__/session-review-panel-recovery.test.ts
new file mode 100644
index 0000000000..c5637efca0
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/session-review-panel-recovery.test.ts
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { act, createElement } from 'react';
+import { createRoot } from 'react-dom/client';
+import { LocaleProvider } from '@maka/ui';
+import {
+  type WorkbarServices,
+  createFakeWorkbarServices,
+  WorkbarServicesProvider,
+  SessionReviewPanel,
+} from '../../renderer/features/workbar/testing.js';
+
+test('a saved failing comparison keeps the picker available and can recover', async () => {
+  const { document, restore } = installDom();
+  const container = document.querySelector('#root');
+  assert.ok(container);
+  const root = createRoot(container);
+  const requests: Array<string | undefined> = [];
+  const branches = {
+    currentBranch: 'feature',
+    baseBranchOptions: [
+      { label: 'main', value: 'refs/heads/main' },
+      { label: 'gh-pages', value: 'refs/heads/gh-pages' },
+    ],
+  };
+  const review: WorkbarServices['review'] = {
+    read: async ({ baseBranch }) => {
+      requests.push(baseBranch);
+      if (baseBranch === 'refs/heads/gh-pages') {
+        return { ok: false, reason: 'git_failed', branches };
+      }
+      return {
+        ok: true,
+        snapshot: {
+          ...branches, source: 'branch', repositoryRoot: '/repo',
+          baseBranch: 'refs/heads/main', revision: 'recovered',
+          files: [], additions: 0, deletions: 0, truncated: false,
+        },
+      };
+    },
+    subscribeSessionEvents: () => () => undefined,
+  };
+  const services = createFakeWorkbarServices({ review });
+  try {
+    services.reviewBaseBranchPreference.write('saved-session', 'refs/heads/gh-pages');
+    await act(async () => {
+      root.render(createElement(LocaleProvider, {
+        locale: 'en',
+        children: createElement(WorkbarServicesProvider, { services },
+          createElement(SessionReviewPanel, { sessionId: 'saved-session', active: true })),
+      }));
+    });
+    assert.match(container.textContent ?? '', /Could not read Git workspace changes/);
+    const trigger = container.querySelector<HTMLButtonElement>('.maka-session-review-base-branch button');
+    assert.ok(trigger, 'failed initial read must preserve a comparison picker');
+    await act(async () => { trigger.click(); });
+    const main = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find(
+      (option) => option.textContent === 'main');
+    assert.ok(main, 'main remains selectable after the diff fails');
+    await act(async () => { main.click(); });
+    assert.deepEqual(requests, ['refs/heads/gh-pages', 'refs/heads/main']);
+    assert.doesNotMatch(container.textContent ?? '', /Could not read Git workspace changes/);
+    assert.ok(container.querySelector<HTMLButtonElement>('.maka-session-review-base-branch button'));
+  } finally {
+    await act(async () => { root.unmount(); });
+    restore();
+  }
+});
+
+for (const reason of ['not_git_repository', 'workspace_unavailable', 'git_failed'] as const) {
+  test(`a refresh clears stale branch options after ${reason}`, async () => {
+    const { document, restore } = installDom();
+    const container = document.querySelector('#root');
+    assert.ok(container);
+    const root = createRoot(container);
+    let unavailable = false;
+    const services = createFakeWorkbarServices({ review: {
+      read: async () => unavailable
+        ? { ok: false, reason }
+        : { ok: true, snapshot: {
+          source: 'branch', repositoryRoot: '/repo', currentBranch: 'feature',
+          baseBranch: 'refs/heads/main',
+          baseBranchOptions: [{ label: 'main', value: 'refs/heads/main' }],
+          revision: 'initial', files: [], additions: 0, deletions: 0, truncated: false,
+        } },
+      subscribeSessionEvents: () => () => undefined,
+    } });
+    const render = (active: boolean) => root.render(createElement(LocaleProvider, {
+      locale: 'en',
+      children: createElement(WorkbarServicesProvider, { services },
+        createElement(SessionReviewPanel, { sessionId: 'vanished-repo', active })),
+    }));
+    try {
+      await act(async () => { render(true); });
+      assert.ok(container.querySelector('.maka-session-review-base-branch'));
+      await act(async () => { render(false); });
+      unavailable = true;
+      await act(async () => { render(true); });
+      assert.equal(container.querySelector('.maka-session-review-base-branch'), null);
+    } finally {
+      await act(async () => { root.unmount(); });
+      restore();
+    }
+  });
+}
+
+test('a disappeared saved branch clears the pin and retries with the dynamic default', async () => {
+  const { document, restore } = installDom();
+  const container = document.querySelector('#root');
+  assert.ok(container);
+  const root = createRoot(container);
+  const sessionId = 'disappeared-branch-session';
+  const requests: Array<string | undefined> = [];
+  const review: WorkbarServices['review'] = {
+    read: async ({ baseBranch }) => {
+      requests.push(baseBranch);
+      if (requests.length === 1) {
+        return { ok: false, reason: 'invalid_base_branch', branches: {
+          currentBranch: 'feature',
+          baseBranchOptions: [{ label: 'main', value: 'refs/heads/main' }],
+        } };
+      }
+      assert.equal(services.reviewBaseBranchPreference.read(sessionId), null, 'clear storage before retrying');
+      return {
+        ok: true,
+        snapshot: {
+          currentBranch: 'feature',
+          baseBranchOptions: [{ label: 'main', value: 'refs/heads/main' }],
+          source: 'branch', repositoryRoot: '/repo',
+          baseBranch: 'refs/heads/main', revision: 'recovered',
+          files: [], additions: 0, deletions: 0, truncated: false,
+        },
+      };
+    },
+    subscribeSessionEvents: () => () => undefined,
+  };
+  const services = createFakeWorkbarServices({ review });
+  try {
+    services.reviewBaseBranchPreference.write(sessionId, 'refs/heads/gh-pages');
+    await act(async () => {
+      root.render(createElement(LocaleProvider, {
+        locale: 'en',
+        children: createElement(WorkbarServicesProvider, { services },
+          createElement(SessionReviewPanel, { sessionId, active: true })),
+      }));
+    });
+    assert.deepEqual(requests, ['refs/heads/gh-pages', undefined]);
+    assert.equal(services.reviewBaseBranchPreference.read(sessionId), null, 'the resolved default must remain unpinned');
+    const trigger = container.querySelector<HTMLButtonElement>('.maka-session-review-base-branch button');
+    assert.ok(trigger, 'automatic recovery restores the comparison picker');
+    assert.match(trigger.textContent ?? '', /main/);
+    await act(async () => { trigger.click(); });
+    const selected = document.querySelector('[role="option"][aria-selected="true"]');
+    assert.equal(selected?.textContent, 'main', 'the resolved default is selected in the picker');
+    assert.equal(container.querySelector('[role="alert"]'), null, 'recovery leaves no error banner');
+    assert.doesNotMatch(container.textContent ?? '', /Could not read Git workspace changes/);
+    assert.equal(container.querySelector('[aria-busy="true"]'), null);
+  } finally {
+    await act(async () => { root.unmount(); });
+    restore();
+  }
+});
+
+test('a comparison switch spins the picker and dims the stale diff until it lands', async () => {
+  const { document, restore } = installDom();
+  const container = document.querySelector('#root');
+  assert.ok(container);
+  const root = createRoot(container);
+  const branches = {
+    currentBranch: 'feature',
+    baseBranchOptions: [
+      { label: 'main', value: 'refs/heads/main' },
+      { label: 'gh-pages', value: 'refs/heads/gh-pages' },
+    ],
+  };
+  let landSlowRead: (() => void) | undefined;
+  const review: WorkbarServices['review'] = {
+    read: async ({ baseBranch }) => {
+      if (baseBranch === 'refs/heads/gh-pages') {
+        await new Promise<void>((resolve) => { landSlowRead = resolve; });
+      }
+      return {
+        ok: true,
+        snapshot: {
+          ...branches, source: 'branch', repositoryRoot: '/repo',
+          baseBranch: baseBranch ?? 'refs/heads/main', revision: baseBranch ?? 'initial',
+          files: [], additions: 0, deletions: 0, truncated: false,
+        },
+      };
+    },
+    subscribeSessionEvents: () => () => undefined,
+  };
+  const services = createFakeWorkbarServices({ review });
+  try {
+    await act(async () => {
+      root.render(createElement(LocaleProvider, {
+        locale: 'en',
+        children: createElement(WorkbarServicesProvider, { services },
+          createElement(SessionReviewPanel, { sessionId: 'switch-session', active: true })),
+      }));
+    });
+    assert.equal(container.querySelector('.maka-session-review-switching'), null);
+    const trigger = container.querySelector<HTMLButtonElement>('.maka-session-review-base-branch button');
+    assert.ok(trigger);
+    await act(async () => { trigger.click(); });
+    const ghPages = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find(
+      (option) => option.textContent === 'gh-pages');
+    assert.ok(ghPages);
+    await act(async () => { ghPages.click(); });
+    assert.ok(container.querySelector('.maka-session-review-switching'), 'the panel reports the pending switch');
+    assert.ok(container.querySelector('.maka-session-review-base-branch [aria-busy="true"]'), 'the picker spins while the read is in flight');
+    await act(async () => { landSlowRead?.(); });
+    assert.equal(container.querySelector('.maka-session-review-switching'), null);
+    assert.equal(container.querySelector('.maka-session-review-base-branch [aria-busy="true"]'), null);
+  } finally {
+    await act(async () => { root.unmount(); });
+    restore();
+  }
+});
+
+function installDom() {
+  const { document, window } = parseHTML('<html><body><div id="root"></div></body></html>');
+  const globals = {
+    document, window,
+    matchMedia: (media: string) => ({
+      matches: false,
+      media,
+      onchange: null,
+      addListener() {},
+      removeListener() {},
+      addEventListener() {},
+      removeEventListener() {},
+      dispatchEvent: () => true,
+    }),
+    HTMLElement: window.HTMLElement,
+    HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
+    requestAnimationFrame: () => 1,
+    cancelAnimationFrame: () => undefined,
+    IS_REACT_ACT_ENVIRONMENT: true,
+  };
+  const previous = new Map(Object.keys(globals).map((key) =>
+    [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
+  for (const [key, value] of Object.entries(globals)) {
+    Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
+  }
+  return {
+    document,
+    restore: () => {
+      for (const [key, descriptor] of previous) {
+        if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+        else Reflect.deleteProperty(globalThis, key);
+      }
+    },
+  };
+}
diff --git a/apps/desktop/src/main/git-review-main.ts b/apps/desktop/src/main/git-review-main.ts
index a75b209c13..1c39775f19 100644
--- a/apps/desktop/src/main/git-review-main.ts
+++ b/apps/desktop/src/main/git-review-main.ts
@@ -24,6 +24,8 @@ import { isAbsolute, relative, resolve } from 'node:path';
 import { promisify } from 'node:util';
 import { countDiffLineStats } from '@maka/core/unified-diff';
 import {
+  type GitReviewBaseBranchOption,
+  type GitReviewBranchContext,
   type GitReviewFile,
   type GitReviewFileStatus,
   type GitReviewReadResult,
@@ -47,6 +49,7 @@ export async function readGitReview(
   runGit: GitReviewCommandRunner = runGitCommand,
   requestedBaseBranch?: string,
 ): Promise<GitReviewReadResult> {
+  let branches: GitReviewBranchContext | undefined;
   try {
     const repositoryRoot = await resolveProjectRoot([cwd]);
     if (!(await resolveProjectGitInfo(repositoryRoot)).isGitRepo) {
@@ -61,17 +64,17 @@ export async function readGitReview(
       source === 'branch' && hasHead
         ? await listBaseBranches(repositoryRoot, runGit)
         : [];
-    if (
-      source === 'branch' &&
-      requestedBaseBranch != null &&
-      !baseBranchOptions.includes(requestedBaseBranch)
-    ) {
-      return { ok: false, reason: 'invalid_base_branch' };
+    if (source === 'branch') branches = { currentBranch, baseBranchOptions };
+    const requestedOption = requestedBaseBranch == null
+      ? undefined
+      : baseBranchOptions.find((option) => option.value === requestedBaseBranch);
+    if (source === 'branch' && requestedBaseBranch != null && !requestedOption) {
+      return { ok: false, reason: 'invalid_base_branch', branches };
     }
     const baseBranch =
       source === 'branch' && hasHead
-        ? requestedBaseBranch ??
-          (await resolveBaseBranch(repositoryRoot, currentBranch, runGit))
+        ? requestedOption?.value ??
+          (await resolveBaseBranch(repositoryRoot, baseBranchOptions, runGit))
         : null;
     const branchComparison =
       source === 'branch' && baseBranch
@@ -139,9 +142,9 @@ export async function readGitReview(
     };
   } catch (error) {
     if (isUnbornRepositoryError(error)) {
-      return { ok: false, reason: 'unborn_repository' };
+      return { ok: false, reason: 'unborn_repository', ...(branches ? { branches } : {}) };
     }
-    return { ok: false, reason: 'git_failed' };
+    return { ok: false, reason: 'git_failed', ...(branches ? { branches } : {}) };
   }
 }
 
@@ -168,7 +171,7 @@ async function readTrackedChanges(input: {
   let truncated = false;
 
   for (const comparison of comparisons) {
-    const [nameStatus, unified] = await Promise.all([
+    const [nameStatus, unifiedResult] = await Promise.all([
       input.runGit(input.repositoryRoot, [
         'diff',
         '--name-status',
@@ -176,7 +179,7 @@ async function readTrackedChanges(input: {
         '--find-renames',
         ...comparison,
       ]),
-      input.runGit(input.repositoryRoot, [
+      runDiffAllowTruncated(input.runGit, input.repositoryRoot, [
         'diff',
         '--no-ext-diff',
         '--no-color',
@@ -187,7 +190,7 @@ async function readTrackedChanges(input: {
       ]),
     ]);
     const entries = parseNameStatus(nameStatus);
-    const chunks = splitUnifiedDiff(unified);
+    const chunks = splitUnifiedDiff(unifiedResult.stdout);
     for (let index = 0; index < entries.length; index += 1) {
       const entry = entries[index]!;
       const diff = chunks[index] ?? '';
@@ -203,11 +206,44 @@ async function readTrackedChanges(input: {
       diffChars += diff.length;
     }
     if (truncated) break;
+    // The diff itself was cut off at the buffer limit: keep what we read and
+    // say so, rather than failing the whole review on a huge branch diff.
+    if (unifiedResult.truncated) {
+      truncated = true;
+      break;
+    }
   }
 
   return { files: dedupeReviewFiles(files), diffChars, truncated };
 }
 
+/**
+ * A branch diff can exceed the child process buffer on far-diverged branches.
+ * Node hands back the bytes it managed to read, which the caller caps at
+ * REVIEW_MAX_DIFF_CHARS anyway, so overflow degrades to a truncated diff.
+ */
+async function runDiffAllowTruncated(
+  runGit: GitReviewCommandRunner,
+  root: string,
+  args: readonly string[],
+): Promise<{ stdout: string; truncated: boolean }> {
+  try {
+    return { stdout: await runGit(root, args), truncated: false };
+  } catch (error) {
+    const partial = maxBufferStdout(error);
+    if (partial === null) throw error;
+    return { stdout: partial, truncated: true };
+  }
+}
+
+function maxBufferStdout(error: unknown): string | null {
+  if (!(error instanceof Error)) return null;
+  const { code, stdout } = error as { code?: unknown; stdout?: unknown };
+  return code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof stdout === 'string'
+    ? stdout
+    : null;
+}
+
 async function readUntrackedChanges(
   repositoryRoot: string,
   runGit: GitReviewCommandRunner,
@@ -361,29 +397,38 @@ function dedupeReviewFiles(files: readonly GitReviewFile[]): GitReviewFile[] {
   return [...byPath.values()];
 }
 
+// The branches a review is most likely to compare against, in the order a
+// reader expects them. `origin/HEAD` stays selectable: it is the remote's
+// declared default, and the one name that survives a rename of that branch.
+const BASE_BRANCH_PRIORITY = [
+  'refs/remotes/origin/HEAD',
+  'refs/remotes/origin/main',
+  'refs/remotes/origin/master',
+  'refs/heads/main',
+  'refs/heads/master',
+];
+
+// The branch a review falls back to when the caller names none. The remote's
+// resolved default comes first so the panel shows a concrete name, then the
+// shared priority order covers repositories whose origin/HEAD is unset.
 async function resolveBaseBranch(
   repositoryRoot: string,
-  currentBranch: string | null,
+  options: readonly GitReviewBaseBranchOption[],
   runGit: GitReviewCommandRunner,
 ): Promise<string | null> {
   const remoteHead = cleanLine(
     await runGit(repositoryRoot, [
       'symbolic-ref',
       '--quiet',
-      '--short',
       'refs/remotes/origin/HEAD',
     ]).catch(() => ''),
   );
-  const candidates = [
-    remoteHead,
-    'origin/main',
-    'origin/master',
-    'main',
-    'master',
-  ].filter((candidate): candidate is string => Boolean(candidate));
+  const candidates = [...new Set([remoteHead, ...BASE_BRANCH_PRIORITY])].filter(
+    (candidate): candidate is string => Boolean(candidate),
+  );
   for (const candidate of candidates) {
-    if (candidate === currentBranch) continue;
-    if (await gitRefExists(repositoryRoot, candidate, runGit)) return candidate;
+    if (options.some((option) => option.value === candidate) &&
+        await gitRefExists(repositoryRoot, candidate, runGit)) return candidate;
   }
   return null;
 }
@@ -391,16 +436,32 @@ async function resolveBaseBranch(
 async function listBaseBranches(
   repositoryRoot: string,
   runGit: GitReviewCommandRunner,
-): Promise<string[]> {
+): Promise<GitReviewBaseBranchOption[]> {
   const output = await runGit(repositoryRoot, [
     'for-each-ref',
-    '--format=%(refname:short)',
+    '--format=%(refname)',
     'refs/heads',
     'refs/remotes',
   ]);
-  return [...new Set(output.split('\n').map((line) => line.trim()).filter(Boolean))]
-    .filter((branch) => !branch.endsWith('/HEAD'))
-    .sort((left, right) => left.localeCompare(right));
+  const branches: GitReviewBaseBranchOption[] = [];
+  for (const value of new Set(output.split('\n').map((line) => line.trim()))) {
+    if (value.startsWith('refs/heads/')) {
+      branches.push({ label: value.slice('refs/heads/'.length), value });
+    } else if (value.startsWith('refs/remotes/') &&
+      (value === 'refs/remotes/origin/HEAD' || !value.endsWith('/HEAD'))) {
+      branches.push({ label: value.slice('refs/remotes/'.length), value });
+    }
+  }
+  return branches.sort((left, right) => {
+    const leftRank = BASE_BRANCH_PRIORITY.indexOf(left.value);
+    const rightRank = BASE_BRANCH_PRIORITY.indexOf(right.value);
+    if (leftRank !== rightRank) {
+      if (leftRank === -1) return 1;
+      if (rightRank === -1) return -1;
+      return leftRank - rightRank;
+    }
+    return left.label.localeCompare(right.label);
+  });
 }
 
 async function gitRefExists(
diff --git a/apps/desktop/src/renderer/features/workbar/README.md b/apps/desktop/src/renderer/features/workbar/README.md
index f49cbdd94a..214870b953 100644
--- a/apps/desktop/src/renderer/features/workbar/README.md
+++ b/apps/desktop/src/renderer/features/workbar/README.md
@@ -50,6 +50,17 @@ remounted when the active session changes.
 - `selectors.rightCollapsed` drives the titlebar restore affordance and
   `selectors.hiddenSessionIds` filters ephemeral companion forks from the rail.
 
+## Review comparison preference
+
+`WorkbarServices.reviewBaseBranchPreference` is the feature-owned persistence
+port. It stores only an explicit qualified ref for one Session; `null` leaves
+the comparison unpinned so reads follow the repository default. New Sessions
+start unpinned, and existing choices survive panel remounts and app restarts.
+The panel owns selection and invalid-branch recovery. The Desktop adapter owns
+the localStorage key, JSON format, validation and best-effort error handling.
+Tests and Storybook use an isolated in-memory implementation through the same
+port; they do not persist review choices into the browser profile.
+
 ## Lifecycle invariants
 
 - Review, Work Board, Browser, Files and Inspector tabs are persisted globally.
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index a7c1e6bc52..41e5e4b9bd 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -284,6 +284,15 @@ export interface SideChatSessionPort {
 export interface WorkbarServices {
   popupMenu(input: import('../../../shared/native-menu.js').NativeMenuRequest): Promise<string | null>;
   readonly review: WorkbarReviewService;
+  /**
+   * Explicit qualified refs persist per Session across panel/app restarts.
+   * `null` means unpinned: new Sessions follow their repository's current default.
+   * Unavailable persistence reads as null; writes are best-effort and never throw.
+   */
+  readonly reviewBaseBranchPreference: {
+    read(sessionId: string): string | null;
+    write(sessionId: string, branch: string | null): void;
+  };
   readonly terminal: WorkbarTerminalService;
   readonly browser: WorkbarBrowserService;
   readonly artifacts: WorkbarArtifactsService;
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 7c67e20586..114fd9513b 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -33,6 +33,8 @@ export * from './model/workbar-tool-definitions.js';
 export * from './tools/artifacts/artifact-list-keyboard.js';
 export * from './tools/artifacts/artifact-visibility.js';
 export * from '../../application/contracts/session-inspector/session-inspector-panel-model.js';
+export { SessionReviewPanel } from './tools/review/session-review-panel.js';
+export { SessionReviewBaseBranchPicker } from './tools/review/session-review-base-branch-picker.js';
 export {
   compactNumberFormatter,
   InspectorCompositionSection,
@@ -67,7 +69,15 @@ const noopSubscription = (): (() => void) => () => undefined;
 export function createFakeWorkbarServices(
   overrides: Partial<WorkbarServices> = {},
 ): WorkbarServices {
+  const reviewBaseBranches = new Map<string, string>();
   return {
+    reviewBaseBranchPreference: {
+      read: (sessionId) => reviewBaseBranches.get(sessionId) ?? null,
+      write: (sessionId, branch) => {
+        if (branch === null) reviewBaseBranches.delete(sessionId);
+        else reviewBaseBranches.set(sessionId, branch);
+      },
+    },
     popupMenu: async () => null,
     review: {
       read: async () => {
diff --git a/apps/desktop/src/renderer/features/workbar/tools/review/session-review-base-branch-picker.tsx b/apps/desktop/src/renderer/features/workbar/tools/review/session-review-base-branch-picker.tsx
new file mode 100644
index 0000000000..1825728210
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workbar/tools/review/session-review-base-branch-picker.tsx
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo } from 'react';
+import type { GitReviewBaseBranchOption } from '@maka/core/git-review';
+import { Selector, SelectorOption } from '@astryxdesign/core/Selector';
+import { Text } from '@astryxdesign/core/Text';
+
+/**
+ * Picks the branch the review panel diffs against. Pure props: the panel owns
+ * the selection, its persistence, and the reload.
+ *
+ * The trigger and every option read a real branch name — never an "auto"
+ * pseudo-entry — so the panel always says what it is comparing to. The search
+ * box comes from `Selector` itself; its placeholder and empty copy are already
+ * localized by the Astryx catalog at the renderer root.
+ */
+export function SessionReviewBaseBranchPicker(props: {
+  baseBranch: string | null;
+  baseBranchOptions: readonly GitReviewBaseBranchOption[];
+  /** A comparison is being re-read: the field spins and the panel dims behind it. */
+  isLoading?: boolean;
+  label: string;
+  onSelect: (branch: string) => void;
+}) {
+  const options = useMemo(() => [...props.baseBranchOptions], [props.baseBranchOptions]);
+  return (
+    // The wrapper is what lets review.css cap the panel: Selector portals its
+    // listbox next to the field, not inside the trigger it styles.
+    <div className="maka-session-review-base-branch">
+      <Selector
+        label={props.label}
+        isLabelHidden
+        variant="ghost"
+        size="sm"
+        hasSearch
+        isLoading={props.isLoading}
+        options={options}
+        value={props.baseBranch ?? undefined}
+        renderValue={(option) => (
+          <Text type="inherit" maxLines={1} hasTruncateTooltip>
+            {option.label ?? option.value}
+          </Text>
+        )}
+        renderOption={(option) => (
+          <SelectorOption
+            label={
+              <Text type="inherit" maxLines={1} hasTruncateTooltip>
+                {option.label ?? option.value}
+              </Text>
+            }
+          />
+        )}
+        onChange={props.onSelect}
+        placeholder={props.label}
+        // The trigger names a branch, it is not a call to action: read like
+        // the current branch it sits beside, not like a primary button.
+        style={{
+          color: 'var(--color-text-secondary)',
+          fontSize: 'var(--text-supporting-size)',
+          fontWeight: 'var(--font-weight-normal)',
+        }}
+      />
+    </div>
+  );
+}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx
index 6558cd42ab..2e5947b656 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx
@@ -28,11 +28,12 @@ import { Skeleton } from '@astryxdesign/core/Skeleton';
 import { Text } from '@astryxdesign/core/Text';
 import { redactSecrets as displayRedactSecrets } from '@maka/core/display-redaction';
 import { generalizedErrorMessageForLocale } from '@maka/core/redaction';
-import { type GitReviewReadResult } from '@maka/core/git-review';
+import { type GitReviewBranchContext, type GitReviewReadResult } from '@maka/core/git-review';
 import { DiffCodePreview, useUiLocale } from '@maka/ui';
-import { ICON_SIZE, GitBranch } from '@maka/ui/icons';
-import { getDesktopConversationCopy } from '../../../../locales/conversation-copy';
+import { ICON_SIZE, ArrowRight, GitBranch } from '@maka/ui/icons';
+import { getDesktopConversationCopy } from '../../../../locales/conversation-copy.js';
 import { useWorkbarServices } from '../../services-context.js';
+import { SessionReviewBaseBranchPicker } from './session-review-base-branch-picker.js';
 
 const REVIEW_FILE_PAGE_SIZE = 20;
 const REVIEW_DIFF_LINE_CAP = 500;
@@ -72,25 +73,62 @@ export function SessionReviewPanel(props: {
   sessionId: string;
   active: boolean;
 }) {
-  const { review } = useWorkbarServices();
+  const { review, reviewBaseBranchPreference } = useWorkbarServices();
   const locale = useUiLocale();
   const copy = getDesktopConversationCopy(locale).reviewPanel;
   const [gitResult, setGitResult] = useState<GitReviewReadResult | null>(null);
+  const [branches, setBranches] = useState<GitReviewBranchContext | null>(null);
   const [loading, setLoading] = useState(false);
+  // Distinct from `loading`: a background refresh must not flash the switch
+  // feedback, so only a user's pick drives it.
+  const [switching, setSwitching] = useState(false);
   const [visibleFileCount, setVisibleFileCount] = useState(REVIEW_FILE_PAGE_SIZE);
   const [error, setError] = useState<string | null>(null);
+  const [baseBranch, setBaseBranch] = useState(() =>
+    reviewBaseBranchPreference.read(props.sessionId),
+  );
   const revisionRef = useRef(0);
+  const displayedBaseBranchRef = useRef<string | null | undefined>(undefined);
+  // Read the latest selection without restarting the subscription effect.
+  // WorkbarSurface keys this panel by sessionId, so each Session initializes afresh.
+  const baseBranchRef = useRef(baseBranch);
 
   const load = useCallback(async () => {
     const revision = ++revisionRef.current;
     setLoading(true);
     setError(null);
-    try {
-      const nextGit = await review.read({
+    const readReview = (selection: string | null) =>
+      review.read({
         sessionId: props.sessionId,
         source: 'branch',
+        baseBranch: selection ?? undefined,
       });
+    try {
+      let nextGit = await readReview(baseBranchRef.current);
       if (revision !== revisionRef.current) return;
+      if (
+        !nextGit.ok &&
+        nextGit.reason === 'invalid_base_branch' &&
+        baseBranchRef.current !== null
+      ) {
+        // The pinned branch is gone. Drop it and re-read once: the retry has no
+        // selection left to reject, so this cannot loop.
+        if (nextGit.branches) setBranches(nextGit.branches);
+        baseBranchRef.current = null;
+        setBaseBranch(null);
+        reviewBaseBranchPreference.write(props.sessionId, null);
+        nextGit = await readReview(null);
+        if (revision !== revisionRef.current) return;
+      }
+      const nextBranches = nextGit.ok ? nextGit.snapshot : nextGit.branches;
+      setBranches(nextBranches ?? null);
+      if (nextGit.ok) {
+        // Preserve expansion on refresh, but start each comparison at page one.
+        if (displayedBaseBranchRef.current !== nextGit.snapshot.baseBranch) {
+          setVisibleFileCount(REVIEW_FILE_PAGE_SIZE);
+          displayedBaseBranchRef.current = nextGit.snapshot.baseBranch;
+        }
+      }
       setGitResult(nextGit);
     } catch (nextError) {
       if (revision === revisionRef.current) {
@@ -99,9 +137,24 @@ export function SessionReviewPanel(props: {
         );
       }
     } finally {
-      if (revision === revisionRef.current) setLoading(false);
+      if (revision === revisionRef.current) {
+        setLoading(false);
+        setSwitching(false);
+      }
     }
-  }, [copy.loadFailed, locale, props.sessionId, review]);
+  }, [copy.loadFailed, locale, props.sessionId, review, reviewBaseBranchPreference]);
+
+  const selectBaseBranch = useCallback(
+    (branch: string) => {
+      if (branch === baseBranchRef.current) return;
+      baseBranchRef.current = branch;
+      setBaseBranch(branch);
+      reviewBaseBranchPreference.write(props.sessionId, branch);
+      setSwitching(true);
+      void load();
+    },
+    [load, props.sessionId, reviewBaseBranchPreference],
+  );
 
   useEffect(() => {
     if (!props.active) return;
@@ -154,9 +207,7 @@ export function SessionReviewPanel(props: {
           ? copy.workspaceUnavailable
           : gitResult.reason === 'unborn_repository'
             ? copy.unbornRepository
-            : gitResult.reason === 'invalid_base_branch'
-              ? copy.invalidBaseBranch
-              : copy.gitFailed;
+            : copy.gitFailed;
   const empty = !loading && !error && !sourceError && gitFiles.length === 0;
 
   return (
@@ -168,7 +219,40 @@ export function SessionReviewPanel(props: {
       aria-label={copy.ariaLabel}
       aria-busy={loading || undefined}
     >
-      <VStack gap={3} align="stretch" width="100%">
+      <VStack
+        gap={3}
+        align="stretch"
+        width="100%"
+        className={switching ? 'maka-session-review-switching' : undefined}
+      >
+        {/* Keep branch selection available when computing the diff fails. */}
+        {branches && branches.baseBranchOptions.length > 0 ? (
+          <HStack
+            align="center"
+            width="100%"
+            className="maka-session-review-branch-row"
+          >
+            {branches.currentBranch ? (
+              <>
+                <Text
+                  type="supporting"
+                  maxLines={1}
+                  className="maka-session-review-current-branch"
+                >
+                  {branches.currentBranch}
+                </Text>
+                <ArrowRight size={ICON_SIZE.control} aria-hidden />
+              </>
+            ) : null}
+            <SessionReviewBaseBranchPicker
+              baseBranch={baseBranch ?? gitSnapshot?.baseBranch ?? null}
+              baseBranchOptions={branches.baseBranchOptions}
+              isLoading={switching}
+              label={copy.baseBranchLabel}
+              onSelect={selectBaseBranch}
+            />
+          </HStack>
+        ) : null}
         {loading && gitResult === null ? (
           <VStack
             gap={2}
diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts
index d1c307999b..084907a6b8 100644
--- a/apps/desktop/src/renderer/locales/conversation-copy.ts
+++ b/apps/desktop/src/renderer/locales/conversation-copy.ts
@@ -143,7 +143,7 @@ export interface DesktopConversationCopy {
     workspaceUnavailable: string;
     unbornRepository: string;
     gitFailed: string;
-    invalidBaseBranch: string;
+    baseBranchLabel: string;
     truncated: string;
     showMore(remaining: number): string;
     hiddenLines(count: number): string;
@@ -408,7 +408,7 @@ const COPY = {
       workspaceUnavailable: '当前任务目录已不可用',
       unbornRepository: 'Git 仓库还没有可比较的提交',
       gitFailed: '无法读取 Git 工作区变化',
-      invalidBaseBranch: '选择的比较分支已不可用',
+      baseBranchLabel: '对比分支',
       truncated: '变化过多,仅显示前一部分文件',
       showMore: (remaining) => `再显示 ${Math.min(20, remaining)} 个文件`,
       hiddenLines: (count) => `另有 ${count} 行未显示`,
@@ -651,7 +651,7 @@ const COPY = {
       workspaceUnavailable: '目前任務目錄已不可用',
       unbornRepository: 'Git 倉庫還沒有可比較的提交',
       gitFailed: '無法讀取 Git 工作區變化',
-      invalidBaseBranch: '選擇的比較分支已不可用',
+      baseBranchLabel: '對比分支',
       truncated: '變化過多,僅顯示前一部分檔案',
       showMore: (remaining) => `再顯示 ${Math.min(20, remaining)} 個檔案`,
       hiddenLines: (count) => `另有 ${count} 行未顯示`,
@@ -885,7 +885,7 @@ const COPY = {
       workspaceUnavailable: 'This task directory is unavailable',
       unbornRepository: 'This Git repository has no commit to compare yet',
       gitFailed: 'Could not read Git workspace changes',
-      invalidBaseBranch: 'The selected comparison branch is unavailable',
+      baseBranchLabel: 'Compare against',
       truncated: 'Too many changes; showing the first files only',
       showMore: (remaining) =>
         `Show ${Math.min(20, remaining)} more file${Math.min(20, remaining) === 1 ? '' : 's'}`,
diff --git a/apps/desktop/src/renderer/platform/desktop/browser-storage.ts b/apps/desktop/src/renderer/platform/desktop/browser-storage.ts
new file mode 100644
index 0000000000..474f6065d7
--- /dev/null
+++ b/apps/desktop/src/renderer/platform/desktop/browser-storage.ts
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export function safeLocalStorageGet(key: string): string | null {
+  try {
+    return localStorage.getItem(key);
+  } catch {
+    return null;
+  }
+}
+
+export function safeLocalStorageSet(key: string, value: string): void {
+  try {
+    localStorage.setItem(key, value);
+  } catch {
+    // Storage may be unavailable in restricted or test renderer contexts.
+  }
+}
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index 8622be3fc4..71c678686b 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -24,6 +24,7 @@ import {
   isTerminalShellRunStatus,
 } from '@maka/core/shell-run';
 import type { WorkbarServices } from '../../features/workbar';
+import { createReviewBaseBranchPreferences } from './review-base-branch-preferences.js';
 import { readSettledMessagesFrom } from './session-message-settlement.js';
 import { expectSessionUpdate } from './create-session-settings-services.js';
 
@@ -105,6 +106,7 @@ export function createDesktopWorkbarServices(
   };
 
   return {
+    reviewBaseBranchPreference: createReviewBaseBranchPreferences(),
     popupMenu: (input) => bridge.appWindow.popupMenu(input),
     review: {
       read: (input) => bridge.gitReview.read(input),
diff --git a/apps/desktop/src/renderer/platform/desktop/review-base-branch-preferences.ts b/apps/desktop/src/renderer/platform/desktop/review-base-branch-preferences.ts
new file mode 100644
index 0000000000..48b6762220
--- /dev/null
+++ b/apps/desktop/src/renderer/platform/desktop/review-base-branch-preferences.ts
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage.js';
+import type { WorkbarServices } from '../../features/workbar/index.js';
+
+export const REVIEW_BASE_BRANCH_STORAGE_KEY = 'maka-session-review-base-branch-v1';
+
+/** Desktop persistence for explicit per-Session comparison choices. */
+export function createReviewBaseBranchPreferences(): WorkbarServices['reviewBaseBranchPreference'] {
+  function readStoredBranches(): Record<string, string> {
+    try {
+      const stored: unknown = JSON.parse(safeLocalStorageGet(REVIEW_BASE_BRANCH_STORAGE_KEY) ?? '{}');
+      if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {};
+      return Object.fromEntries(
+        Object.entries(stored).filter(
+          ([, value]) => typeof value === 'string' && value.length > 0,
+        ),
+      );
+    } catch {
+      return {};
+    }
+  }
+
+  return {
+    read: (sessionId) => readStoredBranches()[sessionId] ?? null,
+    write(sessionId, branch) {
+      const stored = readStoredBranches();
+      if (branch === null) delete stored[sessionId];
+      else stored[sessionId] = branch;
+      safeLocalStorageSet(REVIEW_BASE_BRANCH_STORAGE_KEY, JSON.stringify(stored));
+    },
+  };
+}
diff --git a/apps/desktop/src/renderer/styles/workbar/review.css b/apps/desktop/src/renderer/styles/workbar/review.css
index f6d2f5787b..c451ad54cb 100644
--- a/apps/desktop/src/renderer/styles/workbar/review.css
+++ b/apps/desktop/src/renderer/styles/workbar/review.css
@@ -23,6 +23,50 @@
   overflow-y: auto;
 }
 
+/* The comparison header: `<current> -> <base picker>`. Both branches can
+   shrink for long names while the arrow keeps its size. */
+.maka-session-review-branch-row {
+  justify-content: flex-start;
+}
+
+.maka-session-review-branch-row > svg {
+  flex-shrink: 0;
+}
+
+/* A switch re-reads the whole branch diff and can take a while on a diverged
+   branch. The picker spins and everything below it stays readable but visibly
+   stale until the new snapshot lands. */
+.maka-session-review-switching > *:not(.maka-session-review-branch-row) {
+  opacity: 0.5;
+  pointer-events: none;
+}
+
+.maka-session-review-current-branch,
+.maka-session-review-base-branch {
+  min-width: 0;
+  flex-shrink: 1;
+}
+
+.maka-session-review-current-branch {
+  margin-right: var(--space-3);
+}
+
+/* Astryx gives Selector no panel-width prop, and an uncapped panel grows to the
+   longest branch name. The panel's layer stays inside this wrapper, so the cap
+   lands on the surface; the option's `Item` needs `min-width: 0` to let its
+   label ellipsize instead of pushing the row wider. */
+.maka-session-review-base-branch .astryx-popover-surface {
+  max-width: 280px;
+}
+
+.maka-session-review-base-branch [role='listbox'] {
+  max-height: 288px;
+}
+
+.maka-session-review-base-branch [role='option'] .astryx-item {
+  min-width: 0;
+}
+
 .maka-session-review-file-stats {
   flex-shrink: 0;
   white-space: nowrap;
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 6b5b0439ae..6cf97f8e4c 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -354,8 +354,14 @@ const gitReviewSnapshot: GitReviewSnapshot = {
   source: 'branch',
   repositoryRoot: '/Users/reviewer/maka-agent',
   currentBranch: 'feat/git-authoritative-changes',
-  baseBranch: 'main',
-  baseBranchOptions: ['main', 'release/0.1'],
+  baseBranch: 'refs/remotes/origin/main',
+  baseBranchOptions: [
+    { label: 'origin/HEAD', value: 'refs/remotes/origin/HEAD' },
+    { label: 'origin/main', value: 'refs/remotes/origin/main' },
+    { label: 'main', value: 'refs/heads/main' },
+    { label: 'release/0.1', value: 'refs/heads/release/0.1' },
+    { label: 'origin/feature/payments-migration-2026', value: 'refs/remotes/origin/feature/payments-migration-2026' },
+  ],
   revision: 'storybook-git-review',
   additions: gitReviewFiles.reduce((total, file) => total + file.additions, 0),
   deletions: gitReviewFiles.reduce((total, file) => total + file.deletions, 0),
@@ -1269,6 +1275,45 @@ export const Changes: Story = {
   render: () => <Workbar tab="review" />,
 };
 
+// Real path: 任务工作栏 → 变更 → open the base branch picker in a narrow
+// workbar, search a long branch name, select it, then reopen the comparison menu.
+export const ChangesBaseBranchPicker: Story = {
+  decorators: [bridge()],
+  render: () => <Workbar tab="review" width={320} />,
+  play: async ({ canvasElement }) => {
+    const picker = await waitFor(() => {
+      const element = canvasElement.querySelector<HTMLElement>('.maka-session-review-base-branch');
+      expect(element).not.toBeNull();
+      return element!;
+    });
+    const trigger = within(picker).getByRole('button');
+    await userEvent.click(trigger);
+    const body = within(canvasElement.ownerDocument.body);
+    const listbox = await body.findByRole('listbox');
+    const longName = 'origin/feature/payments-migration-2026';
+    const option = within(listbox).getByRole('option', { name: longName });
+    await waitFor(() => {
+      const surface = picker.querySelector<HTMLElement>('.astryx-popover-surface');
+      expect(surface).not.toBeNull();
+      expect(surface!.getBoundingClientRect().width).toBeGreaterThan(0);
+      expect(surface!.getBoundingClientRect().width).toBeLessThanOrEqual(280);
+      expect(listbox.getBoundingClientRect().height).toBeLessThanOrEqual(288);
+      const text = option.querySelector<HTMLElement>('.astryx-text');
+      expect(text).not.toBeNull();
+      expect(text!.scrollWidth).toBeGreaterThan(text!.clientWidth);
+      expect(getComputedStyle(text!).textOverflow).toBe('ellipsis');
+    });
+    const search = within(picker).getByRole('combobox');
+    await userEvent.type(search, 'payments');
+    await waitFor(() => expect(within(listbox).getAllByRole('option')).toHaveLength(1));
+    await userEvent.click(within(listbox).getByRole('option', { name: longName }));
+    await waitFor(() => expect(trigger).toHaveTextContent(longName));
+    await userEvent.click(trigger);
+    const reopened = await body.findByRole('listbox');
+    expect(within(reopened).getByRole('option', { name: longName })).toHaveAttribute('aria-selected', 'true');
+  },
+};
+
 // Real path: 变更 open, then 浏览器 and 生成文件 opened from [+]. Faces are added to
 // the right of the strip and never reordered, so this is what three of them
 // look like — one selected, two not, which is the only arrangement where the
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 36207f8c47..fcf329e4e2 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -121,6 +121,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx` | shell-chrome-or-panel | Banner, Button, CodeBlock, Spinner | aligned — uses Astryx (Banner, Button, CodeBlock, Spinner) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx` | shell-chrome-or-panel | EmptyState, IconButton, TextInput, Toolbar, Tooltip | aligned — uses Astryx (EmptyState, IconButton, TextInput, Toolbar, Tooltip) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/workbar/tools/review/session-review-base-branch-picker.tsx` | shell-chrome-or-panel | Selector, SelectorOption, Text | aligned — uses Astryx (Selector, SelectorOption, Text) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx` | shell-chrome-or-panel | Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx` | shell-chrome-or-panel | Banner | aligned — uses Astryx (Banner) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState | aligned — uses Astryx (Banner, EmptyState) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 9c89e64a18..6a2b00a65c 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -91,6 +91,7 @@ apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-regi
 apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx
 apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx
 apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
+apps/desktop/src/renderer/features/workbar/tools/review/session-review-base-branch-picker.tsx
 apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx
 apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
 apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx
diff --git a/packages/core/src/git-review.ts b/packages/core/src/git-review.ts
index 9ca9ec90ac..2d59d3e5c8 100644
--- a/packages/core/src/git-review.ts
+++ b/packages/core/src/git-review.ts
@@ -37,12 +37,21 @@ export interface GitReviewFile {
   deletions: number;
 }
 
-export interface GitReviewSnapshot {
+export interface GitReviewBaseBranchOption {
+  label: string;
+  /** Fully qualified branch ref, never a tag or an ambiguous revision. */
+  value: string;
+}
+
+export interface GitReviewBranchContext {
+  currentBranch: string | null;
+  baseBranchOptions: GitReviewBaseBranchOption[];
+}
+
+export interface GitReviewSnapshot extends GitReviewBranchContext {
   source: GitReviewSource;
   repositoryRoot: string;
-  currentBranch: string | null;
   baseBranch: string | null;
-  baseBranchOptions: string[];
   revision: string;
   files: GitReviewFile[];
   additions: number;
@@ -54,6 +63,8 @@ export type GitReviewReadResult =
   | { ok: true; snapshot: GitReviewSnapshot }
   | {
       ok: false;
+      /** Available even when computing the selected branch diff fails. */
+      branches?: GitReviewBranchContext;
       reason:
         | 'workspace_unavailable'
         | 'not_git_repository'
diff --git a/packages/ui/src/astryx-copy.ts b/packages/ui/src/astryx-copy.ts
index 9a72ad5a77..d224e0452b 100644
--- a/packages/ui/src/astryx-copy.ts
+++ b/packages/ui/src/astryx-copy.ts
@@ -88,8 +88,8 @@ export interface AstryxCopy {
   lightbox: { mediaViewer: string; previous: string; next: string };
   menus: { dropdown: string; more: string };
   multiSelector: { clearAll: string; selectAll: string };
-  /** Selector and MultiSelector render the same two search affordances. */
-  search: { options: string; placeholder: string };
+  /** Selector and MultiSelector share search labels; result feedback is Selector-only. */
+  search: { emptySearch: string; options: string; placeholder: string; resultCount: string };
   sideNav: {
     label: string;
     resizeSidebar: string;
@@ -161,7 +161,12 @@ export const ASTRYX_COPY_ZH: AstryxCopy = {
   lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
   menus: { dropdown: '菜单', more: '更多选项' },
   multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
-  search: { options: '搜索选项', placeholder: '搜索…' },
+  search: {
+    options: '搜索选项',
+    placeholder: '搜索…',
+    emptySearch: '没有匹配的选项',
+    resultCount: '{count, number} 个选项',
+  },
   sideNav: {
     label: '侧边导航',
     resizeSidebar: '调整侧边栏宽度',
@@ -233,7 +238,12 @@ export const ASTRYX_COPY_ZH_TW: AstryxCopy = {
   lightbox: { mediaViewer: '媒體檢視器', previous: '上一張', next: '下一張' },
   menus: { dropdown: '選單', more: '更多選項' },
   multiSelector: { clearAll: '清除全部{label}', selectAll: '全選' },
-  search: { options: '搜尋選項', placeholder: '搜尋…' },
+  search: {
+    options: '搜尋選項',
+    placeholder: '搜尋…',
+    emptySearch: '沒有符合的選項',
+    resultCount: '{count, number} 個選項',
+  },
   sideNav: {
     label: '側邊導航',
     resizeSidebar: '調整側邊欄寬度',
diff --git a/packages/ui/src/astryx-i18n.tsx b/packages/ui/src/astryx-i18n.tsx
index ae9820895a..d1cee51485 100644
--- a/packages/ui/src/astryx-i18n.tsx
+++ b/packages/ui/src/astryx-i18n.tsx
@@ -191,6 +191,8 @@ function chineseOverrides(locale: 'zh-CN' | 'zh-TW', astryx: typeof ASTRYX_COPY_
       '@astryx.moreMenu.label': astryx.menus.more,
       '@astryx.selector.searchOptions': astryx.search.options,
       '@astryx.selector.searchPlaceholder': astryx.search.placeholder,
+      '@astryx.selector.emptySearchResults': astryx.search.emptySearch,
+      '@astryx.selector.resultCount': astryx.search.resultCount,
       '@astryx.multiSelector.searchOptions': astryx.search.options,
       '@astryx.multiSelector.searchPlaceholder': astryx.search.placeholder,
       '@astryx.multiSelector.selectPlaceholder': form.selectPlaceholder,


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org