You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by co...@apache.org on 2026/10/09 02:05:55 UTC
(maka) branch main updated: feat(desktop): separate Git review empty and error states (#5967)
This is an automated email from the ASF dual-hosted git repository.
Colafornia 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 af1ccfc910 feat(desktop): separate Git review empty and error states (#5967)
af1ccfc910 is described below
commit af1ccfc910657f08141f5f049f95e39b4413cd7a
Author: Colafornia <co...@gmail.com>
AuthorDate: Fri Oct 9 10:05:50 2026 +0800
feat(desktop): separate Git review empty and error states (#5967)
* feat(desktop): separate Git review empty and error states
A valid non-Git task directory previously rendered the same red error
Banner with Retry as a genuine read failure, even though retrying cannot
turn a directory into a repository. The Changes panel now classifies
GitReviewReadResult failures: not_git_repository, workspace_unavailable
and unborn_repository are capability states with neutral guidance naming
the task's recorded workspace, while git_failed keeps an error Banner
with the underlying detail and a working Retry.
The guidance offers a "Change task folder" action that reuses the
existing Task Entry session workspace recovery picker (#5551),
preserving task identity. A catalog-level session change subscription
re-reads the Session's actual workspace after relocation.
Refs #5940
Generated-by: Devin
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <15...@users.noreply.github.com>
* fix(desktop): address Git review feedback
Limit catalog refreshes to current-task updates, offer neutral refresh actions after external Git setup, and omit command wrappers without diagnostics. Remove the unused workspace class and route Storybook recovery through the production contracts seam.
Refs #5940
Generated-by: Codex
* refactor(desktop): scope workspace recovery to the React tree
Task Entry already contains the Changes panel. Provide its stable recovery command through the existing contracts context and consume it directly in the panel, removing global publication state, listeners, effects, and Workbar callback forwarding. Stories and behavior tests use the same context.
Ablation retained scoped catalog refresh, relocation reads, neutral Refresh, and diagnostic filtering because removing each breaks existing behavior tests. Replace the obsolete publication lifecycle test with existing recovery action coverage through the production context.
Refs #5940
Generated-by: Codex
---------
Co-authored-by: Devin <15...@users.noreply.github.com>
---
.../src/main/__tests__/git-review-main.test.ts | 14 +
.../runtime-host-workspace-ipc-main.test.ts | 121 ++++++
.../session-review-panel-recovery.test.ts | 437 ++++++++++++++-------
.../__tests__/workbar-services-adapter.test.ts | 3 +
apps/desktop/src/main/git-review-main.ts | 22 +-
.../src/main/runtime-host-workspace-ipc-main.ts | 27 +-
.../application/contracts/conversation-copy.ts | 46 ++-
.../session-workspace-recovery-authority.ts | 30 ++
.../features/task-entry/ui/task-entry-provider.tsx | 5 +-
.../desktop/src/renderer/features/workbar/ports.ts | 7 +
.../src/renderer/features/workbar/testing.ts | 1 +
.../workbar/tools/review/session-review-panel.tsx | 132 ++++++-
.../platform/desktop/create-workbar-services.ts | 2 +
apps/desktop/stories/session-workbar.stories.tsx | 86 +++-
packages/core/src/git-review.ts | 5 +
15 files changed, 754 insertions(+), 184 deletions(-)
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 c820ada0d7..2e50809da5 100644
--- a/apps/desktop/src/main/__tests__/git-review-main.test.ts
+++ b/apps/desktop/src/main/__tests__/git-review-main.test.ts
@@ -204,6 +204,8 @@ describe('Git Review snapshot authority', () => {
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.reason, 'git_failed');
+ assert.equal(result.detail, undefined,
+ 'without a diagnostic, a read failure omits the command wrapper');
assert.deepEqual(result.branches, {
currentBranch: 'main',
baseBranchOptions: [
@@ -251,6 +253,18 @@ describe('Git Review snapshot authority', () => {
assert.deepEqual(result.snapshot.files.map((file) => file.path), ['feature.txt']);
});
+ it('reports the real stderr diagnostic rather than the wrapper command text', async () => {
+ const root = await repository();
+ await writeFile(join(root, '.git', 'index'), 'garbage', 'utf8');
+
+ const result = await readGitReview(root, 'unstaged');
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.reason, 'git_failed');
+ assert.match(result.detail ?? '', /fatal:/);
+ assert.equal(result.detail?.includes('Command failed:'), false);
+ });
+
it('returns an explicit non-repository outcome', async () => {
const root = await temporaryRoot();
assert.deepEqual(await readGitReview(root, 'branch'), {
diff --git a/apps/desktop/src/main/__tests__/runtime-host-workspace-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workspace-ipc-main.test.ts
new file mode 100644
index 0000000000..9bd2031fa3
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/runtime-host-workspace-ipc-main.test.ts
@@ -0,0 +1,121 @@
+/*
+ * 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 { strict as assert } from 'node:assert';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, it } from 'node:test';
+import type { GitReviewReadResult } from '@maka/core/git-review';
+import type { IpcHandler } from '../ipc-reconnect-policy.js';
+import { registerRuntimeHostWorkspaceIpc } from '../runtime-host-workspace-ipc-main.js';
+
+type Handler = (event: unknown, raw: unknown) => Promise<GitReviewReadResult>;
+
+const temporaryDirs: string[] = [];
+
+async function temporaryRoot(): Promise<string> {
+ const root = await mkdtemp(join(tmpdir(), 'maka-review-ipc-'));
+ temporaryDirs.push(root);
+ return root;
+}
+
+afterEach(async () => {
+ while (temporaryDirs.length > 0) await rm(temporaryDirs.pop()!, { recursive: true, force: true });
+});
+
+function harness(hostCwd: string) {
+ let handler: Handler | undefined;
+ registerRuntimeHostWorkspaceIpc({
+ ipcMain: {
+ handle: (_channel: string, listener: IpcHandler) => {
+ handler = listener as Handler;
+ },
+ },
+ client: {
+ getSession: async (sessionId: string) => ({
+ id: sessionId,
+ workspace: { hostCwd },
+ }) as never,
+ },
+ });
+ assert.ok(handler);
+ return async (sessionId = 'session-1') =>
+ handler!(undefined, { sessionId, source: 'branch' });
+}
+
+describe('git-review:read workspace identity', () => {
+ it('names the task’s recorded directory on a non-Git workspace', async () => {
+ const root = await temporaryRoot();
+ const read = harness(root);
+ const result = await read();
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.reason, 'not_git_repository');
+ assert.equal(result.workspace, root, 'guidance shows the current task’s workspace');
+ });
+
+ it('names the task’s recorded directory even when it is gone', async () => {
+ const gone = join(await temporaryRoot(), 'deleted');
+ const read = harness(gone);
+ const result = await read();
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.reason, 'workspace_unavailable');
+ assert.equal(result.workspace, gone, 'recovery guidance still names the task’s directory');
+ });
+
+ it('names a host-owned workspace its own state, without resolving a local folder', async () => {
+ let handler: Handler | undefined;
+ let lookedUp = false;
+ registerRuntimeHostWorkspaceIpc({
+ ipcMain: {
+ handle: (_channel: string, listener: IpcHandler) => {
+ handler = listener as Handler;
+ },
+ },
+ client: {
+ getSession: async () => {
+ lookedUp = true;
+ throw new Error('must not resolve a local folder for a host-owned workspace');
+ },
+ },
+ allowLocalWorkspace: false,
+ });
+ const result = await handler!(undefined, { sessionId: 'remote-task', source: 'branch' });
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.reason, 'remote_workspace');
+ assert.equal(result.workspace, undefined, 'a host path is not a local folder to name');
+ assert.equal(lookedUp, false, 'the denial happens before any Session lookup');
+ });
+
+ it('rejects a missing Session instead of reading another workspace', async () => {
+ let handler: Handler | undefined;
+ registerRuntimeHostWorkspaceIpc({
+ ipcMain: {
+ handle: (_channel: string, listener: IpcHandler) => {
+ handler = listener as Handler;
+ },
+ },
+ client: { getSession: async () => null as never },
+ });
+ await assert.rejects(() => handler!(undefined, { sessionId: 'ghost', source: 'branch' }));
+ });
+});
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
index c5637efca0..3e16c35a5e 100644
--- a/apps/desktop/src/main/__tests__/session-review-panel-recovery.test.ts
+++ b/apps/desktop/src/main/__tests__/session-review-panel-recovery.test.ts
@@ -18,23 +18,23 @@
*/
import assert from 'node:assert/strict';
-import { test } from 'node:test';
+import { test, type TestContext } from 'node:test';
+import type { GitReviewSnapshot } from '@maka/core/git-review';
import { parseHTML } from 'linkedom';
import { act, createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { LocaleProvider } from '@maka/ui';
+import type { SessionChangedEvent } from '@maka/core/session';
import {
type WorkbarServices,
createFakeWorkbarServices,
WorkbarServicesProvider,
SessionReviewPanel,
} from '../../renderer/features/workbar/testing.js';
+import { SessionWorkspaceRecoveryContext } from '../../renderer/application/contracts/session-workspace-recovery-authority.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);
+test('a saved failing comparison keeps the picker available and can recover', async (t) => {
+ const { document, container, render } = panelHarness(t);
const requests: Array<string | undefined> = [];
const branches = {
currentBranch: 'feature',
@@ -49,86 +49,58 @@ test('a saved failing comparison keeps the picker available and can recover', as
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,
- },
- };
+ return emptyReview({ ...branches, revision: 'recovered' });
},
subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => 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();
- }
+ services.reviewBaseBranchPreference.write('saved-session', 'refs/heads/gh-pages');
+ await render(services, 'saved-session');
+ 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'));
});
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);
+ test(`a refresh clears stale branch options after ${reason}`, async (t) => {
+ const { container, render } = panelHarness(t);
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,
- } },
+ : emptyReview(),
subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => 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();
- }
+ const expected: Record<typeof reason, RegExp> = {
+ not_git_repository: /Changes require a Git repository/,
+ workspace_unavailable: /This task’s folder is unavailable/,
+ git_failed: /Could not read Git workspace changes/,
+ };
+ await render(services, 'vanished-repo', { active: true });
+ assert.ok(container.querySelector('.maka-session-review-base-branch'));
+ await render(services, 'vanished-repo', { active: false });
+ unavailable = true;
+ await render(services, 'vanished-repo', { active: true });
+ assert.equal(container.querySelector('.maka-session-review-base-branch'), null);
+ assert.match(container.textContent ?? '', expected[reason],
+ `the ${reason} state names itself`);
+ assert.doesNotMatch(container.textContent ?? '', /No changes in the current Git workspace/,
+ 'a failed read is never reported as an empty diff');
});
}
-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);
+test('a disappeared saved branch clears the pin and retries with the dynamic default', async (t) => {
+ const { document, container, render } = panelHarness(t);
const sessionId = 'disappeared-branch-session';
const requests: Array<string | undefined> = [];
const review: WorkbarServices['review'] = {
@@ -141,51 +113,29 @@ test('a disappeared saved branch clears the pin and retries with the dynamic def
} };
}
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,
- },
- };
+ return emptyReview({ revision: 'recovered' });
},
subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => 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();
- }
+ services.reviewBaseBranchPreference.write(sessionId, 'refs/heads/gh-pages');
+ await render(services, sessionId);
+ 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);
});
-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);
+test('a comparison switch spins the picker and dims the stale diff until it lands', async (t) => {
+ const { document, container, render } = panelHarness(t);
const branches = {
currentBranch: 'feature',
baseBranchOptions: [
@@ -199,45 +149,248 @@ test('a comparison switch spins the picker and dims the stale diff until it land
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,
- },
- };
+ return emptyReview({
+ ...branches, baseBranch: baseBranch ?? 'refs/heads/main',
+ revision: baseBranch ?? 'initial',
+ });
},
subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => 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();
- }
+ await render(services, 'switch-session');
+ 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);
});
+test('a non-Git task directory shows neutral guidance, its directory and the recovery action — without Retry', async (t) => {
+ const { container, render } = panelHarness(t);
+ let recovered: string | undefined;
+ let initialized = false;
+ const services = createFakeWorkbarServices({ review: {
+ read: async () => initialized
+ ? emptyReview({
+ repositoryRoot: '/tasks/plain', currentBranch: 'main',
+ baseBranch: null, baseBranchOptions: [], revision: 'initialized',
+ })
+ : { ok: false, reason: 'not_git_repository', workspace: '/tasks/plain' },
+ subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => undefined,
+ } });
+ await render(services, 'plain-task', { recover: (sessionId: string) => { recovered = sessionId; } });
+ assert.match(container.textContent ?? '', /Changes require a Git repository/);
+ assert.match(container.textContent ?? '', /\/tasks\/plain/, 'guidance names this task’s directory');
+ assert.equal(container.querySelector('[role="alert"]'), null, 'a capability state is not an error banner');
+ const buttons = Array.from(container.querySelectorAll('button'));
+ assert.equal(buttons.find((button) => button.textContent === 'Retry'), undefined,
+ 're-reading cannot make a directory a repository');
+ const action = buttons.find((button) => button.textContent === 'Change task folder');
+ assert.ok(action, 'the current-task recovery action is offered');
+ await act(async () => { action.click(); });
+ assert.equal(recovered, 'plain-task', 'recovery targets the current task');
+ const refresh = buttons.find((button) => button.textContent === 'Refresh');
+ assert.ok(refresh, 'external Git initialization can be refreshed without Retry');
+ initialized = true;
+ await act(async () => { refresh.click(); });
+ assert.match(container.textContent ?? '', /No changes in the current Git workspace/);
+});
+
+test('an unavailable workspace keeps its directory, a real Retry and the recovery action', async (t) => {
+ const { container, render } = panelHarness(t);
+ let recovered: string | undefined;
+ let restored = false;
+ const services = createFakeWorkbarServices({ review: {
+ read: async () => restored
+ ? emptyReview({ revision: 'restored' })
+ : { ok: false, reason: 'workspace_unavailable', workspace: '/tasks/missing' },
+ subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => undefined,
+ } });
+ await render(services, 'missing-task', { recover: (sessionId: string) => { recovered = sessionId; } });
+ assert.match(container.textContent ?? '', /This task’s folder is unavailable/);
+ assert.match(container.textContent ?? '', /\/tasks\/missing/, 'guidance names this task’s directory');
+ const buttons = Array.from(container.querySelectorAll('button'));
+ const recovery = buttons.find((button) => button.textContent === 'Change task folder');
+ assert.ok(recovery, 'the current-task recovery action is offered');
+ const retry = buttons.find((button) => button.textContent === 'Retry');
+ assert.ok(retry, 'a directory can come back, so Retry stays');
+ await act(async () => { recovery.click(); });
+ assert.equal(recovered, 'missing-task', 'recovery targets the current task');
+ restored = true;
+ await act(async () => { retry.click(); });
+ assert.match(container.textContent ?? '', /No changes in the current Git workspace/,
+ 'Retry re-reads the task’s actual workspace');
+});
+
+test('an unborn repository shows neutral guidance and can refresh after its first commit', async (t) => {
+ const { container, render } = panelHarness(t);
+ let committed = false;
+ const services = createFakeWorkbarServices({ review: {
+ read: async () => committed
+ ? emptyReview({
+ repositoryRoot: '/tasks/new-repo', currentBranch: 'main',
+ baseBranch: null, baseBranchOptions: [], revision: 'committed',
+ })
+ : { ok: false, reason: 'unborn_repository', workspace: '/tasks/new-repo' },
+ subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => undefined,
+ } });
+ await render(services, 'unborn-task');
+ assert.match(container.textContent ?? '', /This Git repository has no commit to compare yet/);
+ assert.equal(container.querySelector('[role="alert"]'), null, 'a capability state is not an error banner');
+ const buttons = Array.from(container.querySelectorAll('button'));
+ assert.equal(buttons.find((button) => button.textContent === 'Retry'), undefined);
+ const refresh = buttons.find((button) => button.textContent === 'Refresh');
+ assert.ok(refresh, 'the first commit can be refreshed without Retry');
+ committed = true;
+ await act(async () => { refresh.click(); });
+ assert.match(container.textContent ?? '', /No changes in the current Git workspace/);
+});
+
+test('a host-owned workspace shows neutral guidance with neither Retry nor recovery', async (t) => {
+ const { container, render } = panelHarness(t);
+ const services = createFakeWorkbarServices({ review: {
+ read: async () => ({ ok: false, reason: 'remote_workspace' }),
+ subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => undefined,
+ } });
+ await render(services, 'remote-task', {
+ recover: () => { throw new Error('recovery cannot apply to a host-owned workspace'); },
+ });
+ assert.match(container.textContent ?? '', /This task’s workspace is managed by a remote Runtime Host/);
+ assert.equal(container.querySelector('[role="alert"]'), null, 'a capability state is not an error banner');
+ const buttons = Array.from(container.querySelectorAll('button'));
+ assert.equal(buttons.find((button) => button.textContent === 'Retry'), undefined);
+ assert.equal(buttons.find((button) => button.textContent === 'Change task folder'), undefined,
+ 'a local-folder recovery cannot apply to a host-owned workspace');
+});
+
+test('a Git read failure keeps the error Banner with its detail and a working Retry', async (t) => {
+ const { container, render } = panelHarness(t);
+ let failed = true;
+ let reads = 0;
+ const services = createFakeWorkbarServices({ review: {
+ read: async () => {
+ reads += 1;
+ return failed
+ ? { ok: false, reason: 'git_failed', detail: 'fatal: unable to read tree' }
+ : emptyReview({ revision: 'recovered' });
+ },
+ subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: () => () => undefined,
+ } });
+ await render(services, 'failed-task');
+ assert.ok(container.querySelector('[role="alert"]'), 'a read failure stays an error');
+ assert.match(container.textContent ?? '', /Could not read Git workspace changes/);
+ assert.match(container.textContent ?? '', /fatal: unable to read tree/, 'the Git detail survives');
+ const retry = Array.from(container.querySelectorAll('button'))
+ .find((button) => button.textContent === 'Retry');
+ assert.ok(retry);
+ failed = false;
+ await act(async () => { retry.click(); });
+ assert.equal(reads, 2, 'Retry re-reads the workspace');
+ assert.match(container.textContent ?? '', /No changes in the current Git workspace/);
+ assert.equal(container.querySelector('[role="alert"]'), null);
+});
+
+test('a workspace catalog change re-reads the task’s actual workspace', async (t) => {
+ const { container, render } = panelHarness(t);
+ let relocated = false;
+ let reads = 0;
+ let onSessionChange: ((event: SessionChangedEvent) => void) | undefined;
+ const services = createFakeWorkbarServices({ review: {
+ read: async () => {
+ reads += 1;
+ return relocated
+ ? emptyReview({ revision: 'relocated' })
+ : { ok: false, reason: 'workspace_unavailable', workspace: '/tasks/missing' };
+ },
+ subscribeSessionEvents: () => () => undefined,
+ subscribeSessionChanges: (handler) => {
+ onSessionChange = handler;
+ return () => { onSessionChange = undefined; };
+ },
+ } });
+ await render(services, 'moving-task');
+ assert.match(container.textContent ?? '', /This task’s folder is unavailable/);
+ relocated = true;
+ // A change about another Session is not this task's recovery.
+ await act(async () => {
+ onSessionChange?.({ reason: 'updated', sessionId: 'other-task', ts: 0 });
+ for (const reason of ['message-appended', 'status-change', 'turn-status-change', 'goal-change'] as const) {
+ onSessionChange?.({ reason, sessionId: 'moving-task', ts: 0 });
+ }
+ onSessionChange?.({ reason: 'created', ts: 0 });
+ onSessionChange?.({ reason: 'updated', ts: 0 });
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ });
+ assert.equal(reads, 1, 'unrelated catalog activity must not re-read Git');
+ assert.match(container.textContent ?? '', /This task’s folder is unavailable/);
+ await act(async () => {
+ onSessionChange?.({ reason: 'updated', sessionId: 'moving-task', ts: 0 });
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ });
+ assert.match(container.textContent ?? '', /No changes in the current Git workspace/,
+ 'recovery refreshes the correct task');
+ assert.equal(reads, 2);
+});
+
+function emptyReview(overrides: Partial<GitReviewSnapshot> = {}) {
+ return {
+ ok: true as const,
+ snapshot: {
+ source: 'branch' as const, repositoryRoot: '/repo', currentBranch: 'feature',
+ baseBranch: 'refs/heads/main',
+ baseBranchOptions: [{ label: 'main', value: 'refs/heads/main' }],
+ revision: 'initial', files: [], additions: 0, deletions: 0, truncated: false,
+ ...overrides,
+ },
+ };
+}
+
+function panelHarness(t: TestContext) {
+ const { document, restore } = installDom();
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ t.after(async () => {
+ try {
+ await act(async () => { root.unmount(); });
+ } finally {
+ restore();
+ }
+ });
+ return {
+ document,
+ container,
+ render: async (
+ services: WorkbarServices,
+ sessionId: string,
+ options: { active?: boolean; recover?: (sessionId: string) => void } = {},
+ ) => {
+ await act(async () => {
+ root.render(createElement(LocaleProvider, {
+ locale: 'en',
+ children: createElement(WorkbarServicesProvider, { services },
+ createElement(SessionWorkspaceRecoveryContext.Provider, { value: options.recover },
+ createElement(SessionReviewPanel, { sessionId, active: options.active ?? true }))),
+ }));
+ });
+ },
+ };
+}
+
function installDom() {
const { document, window } = parseHTML('<html><body><div id="root"></div></body></html>');
const globals = {
diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
index 9bfec6cc4b..c95887a5f7 100644
--- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
+++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
@@ -33,6 +33,7 @@ function createBridgeRecorder(answerOverrides: Record<string, unknown> = {}): {
const calls: RecordedCall[] = [];
const syncMethods = new Set([
'sessions.subscribeEvents',
+ 'sessions.subscribeChanges',
'shellRuns.subscribePtyData',
'shellRuns.subscribeResync',
'browser.setActiveSession',
@@ -204,6 +205,7 @@ describe('createDesktopWorkbarServices', () => {
await services.review.read({ sessionId: 's', source: 'unstaged' });
services.review.subscribeSessionEvents('s', eventHandler)();
+ services.review.subscribeSessionChanges(eventHandler)();
await services.terminal.start('s');
await services.terminal.stop({ sessionId: 's', ref: 'term' });
@@ -288,6 +290,7 @@ describe('createDesktopWorkbarServices', () => {
[
'gitReview.read',
'sessions.subscribeEvents',
+ 'sessions.subscribeChanges',
'shellRuns.start',
'shellRuns.stop',
'shellRuns.attach',
diff --git a/apps/desktop/src/main/git-review-main.ts b/apps/desktop/src/main/git-review-main.ts
index 1c39775f19..1783817412 100644
--- a/apps/desktop/src/main/git-review-main.ts
+++ b/apps/desktop/src/main/git-review-main.ts
@@ -144,7 +144,12 @@ export async function readGitReview(
if (isUnbornRepositoryError(error)) {
return { ok: false, reason: 'unborn_repository', ...(branches ? { branches } : {}) };
}
- return { ok: false, reason: 'git_failed', ...(branches ? { branches } : {}) };
+ return {
+ ok: false,
+ reason: 'git_failed',
+ detail: gitFailureDetail(error),
+ ...(branches ? { branches } : {}),
+ };
}
}
@@ -505,3 +510,18 @@ function isUnbornRepositoryError(error: unknown): boolean {
error.message,
));
}
+
+// The first stderr line names what Git actually refused; cap it so a noisy
+// command cannot flood the panel. execFile wraps failures as "Command failed:
+// <cmd>\n<stderr>", so stderr carries the diagnostic and the message is the
+// fallback for failures that never reached the process. Skip command wrappers
+// when no stderr diagnostic is available (for example, after a timeout).
+function gitFailureDetail(error: unknown): string | undefined {
+ if (!(error instanceof Error)) return undefined;
+ const { stderr } = error as { stderr?: unknown };
+ const source =
+ typeof stderr === 'string' && stderr.trim() ? stderr : error.message;
+ const line = source.split('\n').map((part) => part.trim())
+ .find((part) => part.length > 0 && !part.startsWith('Command failed:'));
+ return line?.slice(0, 300);
+}
diff --git a/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts b/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts
index f2f2d91ca6..494642b43b 100644
--- a/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts
@@ -37,22 +37,27 @@ export function registerRuntimeHostWorkspaceIpc(
): void {
handleReconnectableRead(input.ipcMain, 'git-review:read', async (_event, raw: unknown) => {
if (input.allowLocalWorkspace === false) {
- return { ok: false as const, reason: 'workspace_unavailable' as const };
+ // A host-owned workspace is not missing — the Desktop simply cannot
+ // read it locally. It is a different capability state than a gone
+ // directory, so it gets its own reason: no folder to name, no retry
+ // that could help, no local-folder recovery that applies.
+ return { ok: false as const, reason: 'remote_workspace' as const };
}
const request = readRequest(raw);
- const cwd = await sessionWorkspace(input.client, request.sessionId);
- if (!cwd) return { ok: false as const, reason: 'workspace_unavailable' as const };
- return readGitReview(cwd, request.source, undefined, request.baseBranch);
+ const session = await input.client.getSession(request.sessionId);
+ if (!session) throw new Error(`No such Session: ${request.sessionId}`);
+ // The Session record owns the task's workspace; guidance must name it,
+ // never the application default — even when the directory is gone.
+ const workspace = session.workspace.hostCwd;
+ const directory = await stat(workspace).catch(() => null);
+ if (!directory?.isDirectory()) {
+ return { ok: false as const, reason: 'workspace_unavailable' as const, workspace };
+ }
+ const result = await readGitReview(workspace, request.source, undefined, request.baseBranch);
+ return result.ok ? result : { ...result, workspace };
});
}
-async function sessionWorkspace(client: WorkspaceClient, sessionId: string): Promise<string | null> {
- const session = await client.getSession(sessionId);
- if (!session) throw new Error(`No such Session: ${sessionId}`);
- const workspace = await stat(session.workspace.hostCwd).catch(() => null);
- return workspace?.isDirectory() ? session.workspace.hostCwd : null;
-}
-
function readRequest(value: unknown): {
sessionId: string;
source: GitReviewSource;
diff --git a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts
index d3b1d2e848..00895fec0d 100644
--- a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts
+++ b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts
@@ -141,9 +141,22 @@ export interface DesktopConversationCopy {
/** The panel-empty (tier 2) sentence under `empty`. */
emptyHelp: string;
notGitRepository: string;
+ /** Neutral guidance under `notGitRepository`; the workspace path renders separately. */
+ notGitRepositoryHelp: string;
workspaceUnavailable: string;
+ /** Recovery guidance under `workspaceUnavailable`. */
+ workspaceUnavailableHelp: string;
+ /** Capability state: the Runtime Host owns the workspace; nothing to retry or relocate locally. */
+ remoteWorkspace: string;
+ /** Guidance under `remoteWorkspace`. */
+ remoteWorkspaceHelp: string;
unbornRepository: string;
+ /** Guidance under `unbornRepository`. */
+ unbornRepositoryHelp: string;
gitFailed: string;
+ /** Relocates this task's workspace through the session recovery picker. */
+ chooseTaskFolder: string;
+ refresh: string;
baseBranchLabel: string;
truncated: string;
showMore(remaining: number): string;
@@ -405,10 +418,17 @@ const COPY = {
ariaLabel: 'Git 变更',
empty: '当前 Git 工作区没有变化',
emptyHelp: '提交、暂存或修改文件后,变化会显示在这里。',
- notGitRepository: '当前任务目录不是 Git 仓库',
- workspaceUnavailable: '当前任务目录已不可用',
+ notGitRepository: '变更需要 Git 仓库',
+ notGitRepositoryHelp: '当前任务目录不是 Git 仓库,因此没有可显示的 Git 变更。',
+ workspaceUnavailable: '当前任务目录不可用',
+ workspaceUnavailableHelp: '目录缺失或无法打开。可以重试,或将当前任务移动到其他目录。',
+ remoteWorkspace: '该任务工作区由远程 Runtime Host 管理',
+ remoteWorkspaceHelp: '本机无法读取其 Git 变更。',
unbornRepository: 'Git 仓库还没有可比较的提交',
+ unbornRepositoryHelp: '在仓库中创建提交后,变更会显示在这里。',
gitFailed: '无法读取 Git 工作区变化',
+ chooseTaskFolder: '更改任务目录',
+ refresh: '刷新',
baseBranchLabel: '对比分支',
truncated: '变化过多,仅显示前一部分文件',
showMore: (remaining) => `再显示 ${Math.min(20, remaining)} 个文件`,
@@ -648,10 +668,17 @@ const COPY = {
ariaLabel: 'Git 變更',
empty: '目前 Git 工作區沒有變化',
emptyHelp: '提交、暫存或修改檔案後,變化會顯示在這裡。',
- notGitRepository: '目前任務目錄不是 Git 倉庫',
- workspaceUnavailable: '目前任務目錄已不可用',
+ notGitRepository: '變更需要 Git 倉庫',
+ notGitRepositoryHelp: '目前任務目錄不是 Git 倉庫,因此沒有可顯示的 Git 變更。',
+ workspaceUnavailable: '目前任務目錄不可用',
+ workspaceUnavailableHelp: '目錄缺失或無法開啟。可以重試,或將目前任務移動到其他目錄。',
+ remoteWorkspace: '該任務工作區由遠端 Runtime Host 管理',
+ remoteWorkspaceHelp: '本機無法讀取其 Git 變更。',
unbornRepository: 'Git 倉庫還沒有可比較的提交',
+ unbornRepositoryHelp: '在倉庫中建立提交後,變更會顯示在這裡。',
gitFailed: '無法讀取 Git 工作區變化',
+ chooseTaskFolder: '更改任務目錄',
+ refresh: '重新整理',
baseBranchLabel: '對比分支',
truncated: '變化過多,僅顯示前一部分檔案',
showMore: (remaining) => `再顯示 ${Math.min(20, remaining)} 個檔案`,
@@ -882,10 +909,17 @@ const COPY = {
ariaLabel: 'Git changes',
empty: 'No changes in the current Git workspace',
emptyHelp: 'Committed, staged, and modified files appear here.',
- notGitRepository: 'This task directory is not a Git repository',
- workspaceUnavailable: 'This task directory is unavailable',
+ notGitRepository: 'Changes require a Git repository',
+ notGitRepositoryHelp: 'This task’s folder is not a Git repository, so there are no Git changes to show.',
+ workspaceUnavailable: 'This task’s folder is unavailable',
+ workspaceUnavailableHelp: 'The folder is missing or cannot be opened. Retry, or move this task to another folder.',
+ remoteWorkspace: 'This task’s workspace is managed by a remote Runtime Host',
+ remoteWorkspaceHelp: 'Git changes cannot be read locally for a host-managed workspace.',
unbornRepository: 'This Git repository has no commit to compare yet',
+ unbornRepositoryHelp: 'Changes appear here once the repository has a commit.',
gitFailed: 'Could not read Git workspace changes',
+ chooseTaskFolder: 'Change task folder',
+ refresh: 'Refresh',
baseBranchLabel: 'Compare against',
truncated: 'Too many changes; showing the first files only',
showMore: (remaining) =>
diff --git a/apps/desktop/src/renderer/application/contracts/session-workspace-recovery-authority.ts b/apps/desktop/src/renderer/application/contracts/session-workspace-recovery-authority.ts
new file mode 100644
index 0000000000..1f144e8d02
--- /dev/null
+++ b/apps/desktop/src/renderer/application/contracts/session-workspace-recovery-authority.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { createContext } from 'react';
+
+/**
+ * The Session workspace recovery command's shared seam (#5551). Features may
+ * not import each other, so Task Entry provides its stable picker command to
+ * its descendants through this contract.
+ */
+type SessionWorkspaceRecoveryCommand = (sessionId: string) => void;
+
+export const SessionWorkspaceRecoveryContext =
+ createContext<SessionWorkspaceRecoveryCommand | undefined>(undefined);
diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx
index 57c6ee6789..20069617f3 100644
--- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx
+++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx
@@ -37,6 +37,7 @@ import {
type TaskEntryControllerSelectors,
} from '../controller/use-task-entry-controller.js';
import { taskEntryDraftKey } from '../model/task-entry-selection.js';
+import { SessionWorkspaceRecoveryContext } from '../../../application/contracts/session-workspace-recovery-authority.js';
import type { TaskEntryError } from '../ports.js';
import type { TaskEntryHostModel } from './task-entry-host.js';
@@ -337,7 +338,9 @@ export function TaskEntryRoot({ children }: TaskEntryRootProps) {
const frame = useMemo(() => children(taskEntry), [children, taskEntry]);
return (
<TaskEntryOwnerContext.Provider value={owner}>
- {frame}
+ <SessionWorkspaceRecoveryContext.Provider value={owner.commands.openSessionWorkspaceRecovery}>
+ {frame}
+ </SessionWorkspaceRecoveryContext.Provider>
</TaskEntryOwnerContext.Provider>
);
}
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index 440d36ea29..f74b022e46 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -79,6 +79,13 @@ export interface WorkbarReviewService {
sessionId: string,
handler: (event: SessionEvent) => void,
): WorkbarUnsubscribe;
+ /**
+ * Catalog-level changes, e.g. a workspace relocation — the read re-resolves
+ * the Session's actual workspace afterwards.
+ */
+ subscribeSessionChanges(
+ handler: (event: SessionChangedEvent) => void,
+ ): WorkbarUnsubscribe;
}
export interface WorkbarTerminalService {
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 0d7076fc46..a6fe3f775a 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -92,6 +92,7 @@ export function createFakeWorkbarServices(
throw new Error('Fake review.read is not configured');
},
subscribeSessionEvents: noopSubscription,
+ subscribeSessionChanges: noopSubscription,
},
terminal: {
recover: async () => ({ resources: [], closes: [] }),
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 ec75c31932..be2814b3ee 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
@@ -17,7 +17,7 @@
* under the License.
*/
-import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner';
import { Button } from '@astryxdesign/core/Button';
import { Collapsible, CollapsibleGroup } from '@astryxdesign/core/Collapsible';
@@ -30,8 +30,9 @@ import { redactSecrets as displayRedactSecrets } from '@maka/core/display-redact
import { generalizedErrorMessageForLocale } from '@maka/core/redaction';
import { type GitReviewBranchContext, type GitReviewReadResult } from '@maka/core/git-review';
import { DiffCodePreview, useUiLocale } from '@maka/ui';
-import { ICON_SIZE, ArrowRight, GitBranch } from '@maka/ui/icons';
+import { ICON_SIZE, AlertCircle, ArrowRight, FolderGit2, GitBranch, Monitor } from '@maka/ui/icons';
import { getDesktopConversationCopy } from '../../../../application/contracts/conversation-copy.js';
+import { SessionWorkspaceRecoveryContext } from '../../../../application/contracts/session-workspace-recovery-authority.js';
import { useWorkbarServices } from '../../services-context.js';
import { SessionReviewBaseBranchPicker } from './session-review-base-branch-picker.js';
@@ -74,6 +75,7 @@ export function SessionReviewPanel(props: {
active: boolean;
}) {
const { review, reviewBaseBranchPreference } = useWorkbarServices();
+ const openWorkspaceRecovery = useContext(SessionWorkspaceRecoveryContext);
const locale = useUiLocale();
const copy = getDesktopConversationCopy(locale).reviewPanel;
const [gitResult, setGitResult] = useState<GitReviewReadResult | null>(null);
@@ -173,6 +175,14 @@ export function SessionReviewPanel(props: {
scheduleRefresh();
},
);
+ // A workspace relocation lands as a catalog change, not a Session event:
+ // the next read resolves the task's actual (new) workspace.
+ const unsubscribeChanges = review.subscribeSessionChanges((change) => {
+ if (change.sessionId !== props.sessionId || change.reason !== 'updated') {
+ return;
+ }
+ scheduleRefresh();
+ });
const refreshAfterExternalChange = () => {
if (document.visibilityState === 'hidden') return;
scheduleRefresh();
@@ -186,6 +196,7 @@ export function SessionReviewPanel(props: {
window.removeEventListener('focus', refreshAfterExternalChange);
document.removeEventListener('visibilitychange', refreshAfterExternalChange);
unsubscribe();
+ unsubscribeChanges();
};
}, [load, props.active, props.sessionId, review]);
@@ -198,17 +209,82 @@ export function SessionReviewPanel(props: {
additions: gitSnapshot?.additions ?? 0,
deletions: gitSnapshot?.deletions ?? 0,
};
- const sourceError =
- gitResult?.ok !== false
- ? null
- : gitResult.reason === 'not_git_repository'
- ? copy.notGitRepository
- : gitResult.reason === 'workspace_unavailable'
- ? copy.workspaceUnavailable
- : gitResult.reason === 'unborn_repository'
- ? copy.unbornRepository
- : copy.gitFailed;
- const empty = !loading && !error && !sourceError && gitFiles.length === 0;
+ // A valid non-Git directory, a missing workspace and an unborn repository
+ // are capability states with their own next action — not read failures, so
+ // they take neutral guidance instead of the error Banner.
+ const failure = gitResult?.ok === false ? gitResult : null;
+ const recoveryAction = openWorkspaceRecovery ? (
+ <Button
+ variant="secondary"
+ size="sm"
+ label={copy.chooseTaskFolder}
+ onClick={() => openWorkspaceRecovery(props.sessionId)}
+ />
+ ) : null;
+ const refreshAction = (
+ <Button
+ variant="ghost"
+ size="sm"
+ label={copy.refresh}
+ isLoading={loading}
+ onClick={() => void load()}
+ />
+ );
+ const guidance = (() => {
+ switch (failure?.reason) {
+ case 'not_git_repository':
+ return {
+ icon: <FolderGit2 size={ICON_SIZE.empty} aria-hidden />,
+ title: copy.notGitRepository,
+ help: copy.notGitRepositoryHelp,
+ actions: (
+ <HStack gap={2} align="center">
+ {recoveryAction}
+ {refreshAction}
+ </HStack>
+ ),
+ };
+ case 'workspace_unavailable':
+ return {
+ icon: <AlertCircle size={ICON_SIZE.empty} aria-hidden />,
+ title: copy.workspaceUnavailable,
+ help: copy.workspaceUnavailableHelp,
+ // Retry is legitimate: the folder may be back, and after relocation
+ // the read resolves the task's actual current workspace.
+ actions: (
+ <HStack gap={2} align="center">
+ {recoveryAction}
+ <Button
+ variant="ghost"
+ size="sm"
+ label={copy.retry}
+ isLoading={loading}
+ onClick={() => void load()}
+ />
+ </HStack>
+ ),
+ };
+ case 'unborn_repository':
+ return {
+ icon: <GitBranch size={ICON_SIZE.empty} aria-hidden />,
+ title: copy.unbornRepository,
+ help: copy.unbornRepositoryHelp,
+ actions: refreshAction,
+ };
+ case 'remote_workspace':
+ return {
+ icon: <Monitor size={ICON_SIZE.empty} aria-hidden />,
+ title: copy.remoteWorkspace,
+ help: copy.remoteWorkspaceHelp,
+ // Nothing local can help: the folder is not gone, and neither a
+ // local-folder recovery nor a retry changes who owns it.
+ actions: null,
+ };
+ default:
+ return null;
+ }
+ })();
+ const empty = !loading && !error && !failure && gitFiles.length === 0;
return (
<Section
@@ -297,12 +373,38 @@ export function SessionReviewPanel(props: {
}
/>
) : null}
+ {guidance ? (
+ /* Panel-level capability state (DESIGN.md §10 tier 2): the reason is
+ a directory the panel cannot use, not an absent diff, so it gets
+ the same icon + description treatment as the real empty state. */
+ (<EmptyState
+ icon={guidance.icon}
+ title={guidance.title}
+ description={guidance.help}
+ actions={
+ failure?.workspace || guidance.actions ? (
+ <VStack gap={2} align="center">
+ {failure?.workspace ? (
+ <Text
+ type="code"
+ maxLines={2}
+ >
+ {failure.workspace}
+ </Text>
+ ) : null}
+ {guidance.actions}
+ </VStack>
+ ) : undefined
+ }
+ />)
+ ) : null}
{/* A source that cannot be read is a failure, not an absence — it takes
the same Banner the load error above does, not an EmptyState. */}
- {sourceError ? (
+ {failure && !guidance ? (
<Banner
status="error"
- title={sourceError}
+ title={copy.gitFailed}
+ description={failure.detail ? displayRedactSecrets(failure.detail) : undefined}
endContent={
<Button
variant="ghost"
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 319fdb82e5..84580caacd 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -117,6 +117,8 @@ export function createDesktopWorkbarServices(
read: (input) => bridge.gitReview.read(input),
subscribeSessionEvents: (sessionId, handler) =>
bridge.sessions.subscribeEvents(sessionId, handler),
+ subscribeSessionChanges: (handler) =>
+ bridge.sessions.subscribeChanges(handler),
},
terminal: {
start: (sessionId) => bridge.shellRuns.start(sessionId),
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 59731ab619..3f8648b24a 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -29,6 +29,7 @@ import type { SessionTrace } from '@maka/core/session-trace';
import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
import { ChatSurfaceLayout, Composer, ToastProvider } from '@maka/ui';
import { WorkbarServicesProvider } from '../src/renderer/features/workbar';
+import { SessionWorkspaceRecoveryContext } from '../src/renderer/application/contracts/session-workspace-recovery-authority';
import { WorkbarHostView, WorkbarSurface, useWorkbarLayoutState, type WorkbarHostModel } from '../src/renderer/features/workbar/stories';
import {
createFakeWorkbarServices,
@@ -947,6 +948,7 @@ function bridge(options: {
return options.review ?? { ok: true, snapshot: gitReviewSnapshot };
},
subscribeSessionEvents: unsubscribe,
+ subscribeSessionChanges: unsubscribe,
},
terminal: {
recover: async () => ({ resources: [], closes: [] }),
@@ -1126,7 +1128,7 @@ function Workbar(props: {
: withExtras;
const [panels, setPanels] = useState(() => createSessionWorkbarPanelsState(tabsState));
return (
- <ToastProvider>
+ <SessionWorkspaceRecoveryContext.Provider value={noop}><ToastProvider>
<div
className="maka-detail-with-artifacts"
data-preview-focused={focused ? props.tab : undefined}
@@ -1182,7 +1184,7 @@ function Workbar(props: {
}
/>
</div>
- </ToastProvider>
+ </ToastProvider></SessionWorkspaceRecoveryContext.Provider>
);
}
@@ -1407,16 +1409,84 @@ export const ChangesLoadFailed: Story = {
},
};
-// Real path: 任务工作栏 → 变更 when the session cwd is not a Git repository. A
-// source that cannot be read is a failure (error Banner + 重试), not an
-// absence — the other read reasons (workspace unavailable, unborn repo,
-// invalid base branch, git failed) share this branch.
+// Real path: 任务工作栏 → 变更 in a valid but non-Git task directory — the
+// projectless first-run case. A capability state, not a failure: neutral
+// guidance names this task's own directory and offers the current-task
+// recovery action; a 重试 here could only mislead.
export const ChangesSourceNotGit: Story = {
- decorators: [bridge({ review: { ok: false, reason: 'not_git_repository' } })],
+ decorators: [bridge({ review: {
+ ok: false, reason: 'not_git_repository', workspace: '/Users/example/tasks/plain-notes',
+ } })],
+ render: () => <Workbar tab="review" />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText('变更需要 Git 仓库');
+ await canvas.findByText('/Users/example/tasks/plain-notes');
+ await canvas.findByRole('button', { name: '更改任务目录' });
+ expect(canvas.queryByRole('button', { name: '重试' })).toBeNull();
+ expect(canvasElement.querySelector('[role="alert"]')).toBeNull();
+ },
+};
+
+// Real path: 任务工作栏 → 变更 when the task's directory is gone — an
+// existing task whose workspace became unavailable. Recovery guidance keeps
+// this task's recorded directory, a legitimate 重试 (the folder can come
+// back), and the same current-task recovery action.
+export const ChangesWorkspaceUnavailable: Story = {
+ decorators: [bridge({ review: {
+ ok: false, reason: 'workspace_unavailable', workspace: '/Users/example/tasks/moved-away',
+ } })],
+ render: () => <Workbar tab="review" />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText('当前任务目录不可用');
+ await canvas.findByText('/Users/example/tasks/moved-away');
+ await canvas.findByRole('button', { name: '更改任务目录' });
+ await canvas.findByRole('button', { name: '重试' });
+ },
+};
+
+// Real path: 任务工作栏 → 变更 in a repository with no commit yet — another
+// capability state, naming the task's directory with Refresh after a commit.
+export const ChangesUnbornRepository: Story = {
+ decorators: [bridge({ review: {
+ ok: false, reason: 'unborn_repository', workspace: '/Users/example/tasks/fresh-repo',
+ } })],
+ render: () => <Workbar tab="review" />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText('Git 仓库还没有可比较的提交');
+ await canvas.findByText('/Users/example/tasks/fresh-repo');
+ expect(canvas.queryByRole('button', { name: '重试' })).toBeNull();
+ },
+};
+
+// Real path: 任务工作栏 → 变更 on a session whose Runtime Host owns the
+// workspace — the Desktop cannot read it locally. A capability state with
+// neither 重试 nor 更改任务目录: no local action can help.
+export const ChangesRemoteWorkspace: Story = {
+ decorators: [bridge({ review: { ok: false, reason: 'remote_workspace' } })],
+ render: () => <Workbar tab="review" />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText('该任务工作区由远程 Runtime Host 管理');
+ expect(canvas.queryByRole('button', { name: '重试' })).toBeNull();
+ expect(canvas.queryByRole('button', { name: '更改任务目录' })).toBeNull();
+ },
+};
+
+// Real path: 任务工作栏 → 变更 when the Git read itself fails. Unlike a
+// capability state it stays an error Banner: the underlying detail survives
+// and 重试 re-reads this task's workspace.
+export const ChangesGitReadFailed: Story = {
+ decorators: [bridge({ review: {
+ ok: false, reason: 'git_failed', detail: 'fatal: unable to read tree (abc1234)',
+ } })],
render: () => <Workbar tab="review" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await canvas.findByText('当前任务目录不是 Git 仓库');
+ await canvas.findByText('无法读取 Git 工作区变化');
+ await canvas.findByText('fatal: unable to read tree (abc1234)');
await canvas.findByRole('button', { name: '重试' });
},
};
diff --git a/packages/core/src/git-review.ts b/packages/core/src/git-review.ts
index 2d59d3e5c8..39ce7eeb4a 100644
--- a/packages/core/src/git-review.ts
+++ b/packages/core/src/git-review.ts
@@ -65,8 +65,13 @@ export type GitReviewReadResult =
ok: false;
/** Available even when computing the selected branch diff fails. */
branches?: GitReviewBranchContext;
+ /** The Session's recorded workspace, so guidance names its directory. */
+ workspace?: string;
+ /** Underlying read detail for `git_failed`; absent for capability states. */
+ detail?: string;
reason:
| 'workspace_unavailable'
+ | 'remote_workspace'
| 'not_git_repository'
| 'unborn_repository'
| 'invalid_base_branch'
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org