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..288e2e743e 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 @@ -124,6 +124,140 @@ for (const reason of ['not_git_repository', 'workspace_unavailable', 'git_failed }); } +test('a task directory outside any repository guides instead of failing', async () => { + const { document, restore } = installDom(); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + let reads = 0; + const services = createFakeWorkbarServices({ review: { + read: async () => { + reads += 1; + return { ok: false, reason: 'not_git_repository', cwd: '/tmp/plain-task' }; + }, + subscribeSessionEvents: () => () => undefined, + } }); + try { + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(WorkbarServicesProvider, { services }, + createElement(SessionReviewPanel, { sessionId: 'plain-task', active: true })), + })); + }); + assert.match(container.textContent ?? '', /not a Git repository/); + assert.match(container.textContent ?? '', /git init/); + assert.match(container.textContent ?? '', /Task directory: \/tmp\/plain-task/); + const retry = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Retry'); + assert.equal(retry, undefined, 'retrying cannot turn a directory into a repository'); + const refresh = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Refresh'); + assert.ok(refresh, 'git init in a side terminal needs a manual refresh affordance'); + assert.equal(reads, 1); + await act(async () => { refresh.click(); }); + assert.equal(reads, 2, 'refresh re-reads the source after an out-of-band git init'); + } finally { + await act(async () => { root.unmount(); }); + restore(); + } +}); + +test('an unavailable workspace keeps retry and names the recovery path', async () => { + const { document, restore } = installDom(); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const services = createFakeWorkbarServices({ review: { + read: async () => ({ ok: false, reason: 'workspace_unavailable', cwd: '/tmp/vanished-task' }), + subscribeSessionEvents: () => () => undefined, + } }); + try { + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(WorkbarServicesProvider, { services }, + createElement(SessionReviewPanel, { sessionId: 'vanished-task', active: true })), + })); + }); + assert.match(container.textContent ?? '', /unavailable/); + assert.match(container.textContent ?? '', /may have been moved/); + assert.match(container.textContent ?? '', /Task directory: \/tmp\/vanished-task/); + const retry = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Retry'); + assert.ok(retry, 'restoring the directory makes a retry meaningful'); + } finally { + await act(async () => { root.unmount(); }); + restore(); + } +}); + +test('a runtime host without a local workspace states inapplicability instead of failure', async () => { + const { document, restore } = installDom(); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const services = createFakeWorkbarServices({ review: { + read: async () => ({ ok: false, reason: 'local_workspace_disabled' }), + subscribeSessionEvents: () => () => undefined, + } }); + try { + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(WorkbarServicesProvider, { services }, + createElement(SessionReviewPanel, { sessionId: 'remote-target', active: true })), + })); + }); + assert.match(container.textContent ?? '', /not available for this runtime host/); + assert.doesNotMatch( + container.textContent ?? '', + /may have been moved/, + 'no directory was read, so the recovery guidance would mislead', + ); + assert.doesNotMatch(container.textContent ?? '', /Task directory:/); + assert.doesNotMatch(container.textContent ?? '', /Could not read Git workspace changes/); + const retry = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Retry'); + assert.equal(retry, undefined, 'the host never reads this directory, so no retry can succeed'); + } finally { + await act(async () => { root.unmount(); }); + restore(); + } +}); + +for (const reason of ['git_failed', 'unborn_repository'] as const) { + test(`a ${reason} read failure keeps the error banner and retry`, async () => { + const { document, restore } = installDom(); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const services = createFakeWorkbarServices({ review: { + read: async () => ({ ok: false, reason, cwd: '/tmp/live-task' }), + subscribeSessionEvents: () => () => undefined, + } }); + try { + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(WorkbarServicesProvider, { services }, + createElement(SessionReviewPanel, { sessionId: 'failed-read', active: true })), + })); + }); + assert.match(container.textContent ?? '', /Could not read|no commit to compare/); + const retry = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Retry'); + assert.ok(retry, 'a retriable read failure keeps its retry'); + if (reason === 'git_failed') { + assert.doesNotMatch(container.textContent ?? '', /Task directory:/); + } + } 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'); 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..5bc19c0160 100644 --- a/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workspace-ipc-main.ts @@ -37,20 +37,40 @@ 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 }; + // Runtime hosts without a local workspace never read a directory at + // all, so this is not the recoverable `workspace_unavailable`: that + // would show moved/deleted recovery guidance plus a Retry that can + // never succeed (the directory exists — the host just does not read + // it). + return { ok: false as const, reason: 'local_workspace_disabled' 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 workspace = await sessionWorkspace(input.client, request.sessionId); + if (!workspace.readable) { + // The guidance states name the directory they refer to; report the + // path the Session declares even though it cannot be read. + return { + ok: false as const, + reason: 'workspace_unavailable' as const, + cwd: workspace.declared, + }; + } + const result = await readGitReview(workspace.readable, request.source, undefined, request.baseBranch); + return result.ok ? result : { ...result, cwd: workspace.readable }; }); } -async function sessionWorkspace(client: WorkspaceClient, sessionId: string): Promise { +async function sessionWorkspace( + client: WorkspaceClient, + sessionId: string, +): Promise<{ declared: string; readable: 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; + return { + declared: session.workspace.hostCwd, + readable: workspace?.isDirectory() ? session.workspace.hostCwd : null, + }; } function readRequest(value: unknown): { diff --git a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts index d3b1d2e848..2511e73268 100644 --- a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts +++ b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts @@ -141,7 +141,17 @@ export interface DesktopConversationCopy { /** The panel-empty (tier 2) sentence under `empty`. */ emptyHelp: string; notGitRepository: string; + /** Neutral guidance under `notGitRepository`: what Changes needs and the next step. */ + notGitRepositoryHelp: string; workspaceUnavailable: string; + /** Recovery guidance under `workspaceUnavailable`, pointing at existing recovery paths. */ + workspaceUnavailableHelp: string; + /** Neutral note for runtime hosts that never expose a local task directory. */ + runtimeHostWorkspaceUnavailable: string; + /** Why Changes shows nothing for such a runtime host, without recovery guidance. */ + runtimeHostWorkspaceUnavailableHelp: string; + /** Names the task directory a guidance state refers to. */ + taskDirectoryPath(path: string): string; unbornRepository: string; gitFailed: string; baseBranchLabel: string; @@ -155,6 +165,8 @@ export interface DesktopConversationCopy { deleted(count: number): string; loadFailed: string; retry: string; + /** Re-reads the source after an out-of-band change, e.g. `git init` in a side terminal. */ + refresh: string; }; terminalPanel: { ariaLabel: string; @@ -406,7 +418,12 @@ const COPY = { empty: '当前 Git 工作区没有变化', emptyHelp: '提交、暂存或修改文件后,变化会显示在这里。', notGitRepository: '当前任务目录不是 Git 仓库', + notGitRepositoryHelp: '变更基于 Git 历史进行比较,因此该目录需要是 Git 仓库。可在此初始化仓库(git init),或将任务移到已有仓库。', workspaceUnavailable: '当前任务目录已不可用', + workspaceUnavailableHelp: '该目录可能已被移动、删除或暂时无法访问。恢复该目录或切换项目的工作目录后重试。', + runtimeHostWorkspaceUnavailable: 'Changes 在当前运行时主机上不可用', + runtimeHostWorkspaceUnavailableHelp: '该运行时目标不提供本地任务目录,因此没有可查看的 Git 变化。', + taskDirectoryPath: (path) => `任务目录:${path}`, unbornRepository: 'Git 仓库还没有可比较的提交', gitFailed: '无法读取 Git 工作区变化', baseBranchLabel: '对比分支', @@ -420,6 +437,7 @@ const COPY = { deleted: (count) => `删除 ${count}`, loadFailed: '无法读取 Git 变化', retry: '重试', + refresh: '刷新', }, terminalPanel: { ariaLabel: '任务终端', @@ -649,7 +667,12 @@ const COPY = { empty: '目前 Git 工作區沒有變化', emptyHelp: '提交、暫存或修改檔案後,變化會顯示在這裡。', notGitRepository: '目前任務目錄不是 Git 倉庫', + notGitRepositoryHelp: '變更基於 Git 歷史進行比較,因此該目錄需要是 Git 倉庫。可在此初始化倉庫(git init),或將任務移到已有倉庫。', workspaceUnavailable: '目前任務目錄已不可用', + workspaceUnavailableHelp: '該目錄可能已被移動、刪除或暫時無法存取。還原該目錄或切換專案的工作目錄後重試。', + runtimeHostWorkspaceUnavailable: 'Changes 在目前執行階段主機上不可用', + runtimeHostWorkspaceUnavailableHelp: '該執行階段目標不提供本地任務目錄,因此沒有可查看的 Git 變化。', + taskDirectoryPath: (path) => `任務目錄:${path}`, unbornRepository: 'Git 倉庫還沒有可比較的提交', gitFailed: '無法讀取 Git 工作區變化', baseBranchLabel: '對比分支', @@ -663,6 +686,7 @@ const COPY = { deleted: (count) => `刪除 ${count}`, loadFailed: '無法讀取 Git 變化', retry: '重試', + refresh: '重新整理', }, terminalPanel: { ariaLabel: '任務終端', @@ -883,7 +907,15 @@ const COPY = { 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', + notGitRepositoryHelp: + 'Changes reviews Git history, so this directory must be a Git repository. Initialize one here (git init), or move the task to an existing repository.', workspaceUnavailable: 'This task directory is unavailable', + workspaceUnavailableHelp: + "This directory may have been moved, deleted, or become inaccessible. Restore it or switch the project's working directory, then retry.", + runtimeHostWorkspaceUnavailable: 'Changes is not available for this runtime host', + runtimeHostWorkspaceUnavailableHelp: + 'This runtime target does not provide a local task directory, so there are no Git changes to review.', + taskDirectoryPath: (path) => `Task directory: ${path}`, unbornRepository: 'This Git repository has no commit to compare yet', gitFailed: 'Could not read Git workspace changes', baseBranchLabel: 'Compare against', @@ -899,6 +931,7 @@ const COPY = { deleted: (count) => `${count} deleted`, loadFailed: 'Could not read Git changes', retry: 'Retry', + refresh: 'Refresh', }, terminalPanel: { ariaLabel: 'Task terminal', 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..f7b2da1267 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 @@ -198,17 +198,30 @@ export function SessionReviewPanel(props: { additions: gitSnapshot?.additions ?? 0, deletions: gitSnapshot?.deletions ?? 0, }; + // A directory without a repository is a neutral starting point, not a + // failure: retrying cannot create one, so it takes guidance instead of the + // error Banner. + const sourceFailure = gitResult?.ok === false ? gitResult : null; + const guidance = + sourceFailure?.reason === 'not_git_repository' ? sourceFailure : null; + // A missing workspace can recover (restore or relocate the directory), so it + // keeps its Retry and adds the recovery guidance to the Banner. + const unavailable = + sourceFailure?.reason === 'workspace_unavailable' ? sourceFailure : null; + // A runtime host that does not expose a local workspace never reads the + // directory, so Changes is not applicable rather than broken: no recovery + // applies and no Retry could succeed. + const hostUnsupported = + sourceFailure?.reason === 'local_workspace_disabled' ? sourceFailure : null; const sourceError = - gitResult?.ok !== false + sourceFailure === null || guidance || unavailable || hostUnsupported ? 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; + : sourceFailure.reason === 'unborn_repository' + ? copy.unbornRepository + : copy.gitFailed; + const empty = + !loading && !error && !sourceError && !guidance && !unavailable && + !hostUnsupported && gitFiles.length === 0; return (
) : 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. */} + {guidance ? ( + /* Neutral guidance (issue #5940): what Changes needs, the directory + it looked at, and the next step. No Retry — retrying cannot create + a repository — but a low-emphasis Refresh stays: `git init` runs in + a side terminal, and none of the automatic reload triggers fire + while the panel keeps focus. */ + + } + title={copy.notGitRepository} + description={copy.notGitRepositoryHelp} + /> + {guidance.cwd ? ( + + {copy.taskDirectoryPath(guidance.cwd)} + + ) : null} +