Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apps/desktop/src/main/__tests__/session-review-panel-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('button'))
.find((button) => button.textContent === 'Retry');
assert.equal(retry, undefined, 'retrying cannot turn a directory into a repository');
const refresh = Array.from(container.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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');
Expand Down
32 changes: 26 additions & 6 deletions apps/desktop/src/main/runtime-host-workspace-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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): {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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: '对比分支',
Expand All @@ -420,6 +437,7 @@ const COPY = {
deleted: (count) => `删除 ${count}`,
loadFailed: '无法读取 Git 变化',
retry: '重试',
refresh: '刷新',
},
terminalPanel: {
ariaLabel: '任务终端',
Expand Down Expand Up @@ -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: '對比分支',
Expand All @@ -663,6 +686,7 @@ const COPY = {
deleted: (count) => `刪除 ${count}`,
loadFailed: '無法讀取 Git 變化',
retry: '重試',
refresh: '重新整理',
},
terminalPanel: {
ariaLabel: '任務終端',
Expand Down Expand Up @@ -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',
Expand All @@ -899,6 +931,7 @@ const COPY = {
deleted: (count) => `${count} deleted`,
loadFailed: 'Could not read Git changes',
retry: 'Retry',
refresh: 'Refresh',
},
terminalPanel: {
ariaLabel: 'Task terminal',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Section
Expand Down Expand Up @@ -297,8 +310,72 @@ export function SessionReviewPanel(props: {
}
/>
) : 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 ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the not-a-repository state no longer has a reload affordance, but its copy suggests an in-place fix. notGitRepositoryHelp says "Initialize one here (git init)". The premise "retrying cannot turn a directory into a repository" holds only until the user does exactly that. The panel reloads on window focus/visibilitychange, on agent tool_result/complete, and on re-activation. But the workbar has a terminal tool (workbar-tool-definitions.ts, right/bottom placement), so it can sit in the bottom panel next to Changes on the right. Running git init there fires none of those triggers, and the panel stays on "not a Git repository" with no button until the user changes focus or tab. Consider keeping a low-emphasis Retry/Refresh here, or dropping the "here (git init)" wording. Note that after git init the next read becomes unborn_repository, which does show Retry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 795f4c1: the guidance keeps the git-init wording and gains a low-emphasis ghost Refresh button that re-reads the source. Once git init lands, the next read reports unborn_repository, which already carries Retry.

/* 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. */
<VStack gap={2} align="center" width="100%">
<EmptyState
icon={<GitBranch size={ICON_SIZE.empty} aria-hidden />}
title={copy.notGitRepository}
description={copy.notGitRepositoryHelp}
/>
{guidance.cwd ? (
<Text type="code" color="secondary" display="block">
{copy.taskDirectoryPath(guidance.cwd)}
</Text>
) : null}
<Button
variant="ghost"
size="sm"
label={copy.refresh}
isLoading={loading}
onClick={() => void load()}
/>
</VStack>
) : null}
{hostUnsupported ? (
/* Neutral, like the guidance above: nothing failed — this runtime
target simply does not expose a local task directory, so there is
no recovery to name, no directory to point at, and no Retry that
could succeed. */
<VStack gap={2} align="center" width="100%">
<EmptyState
icon={<GitBranch size={ICON_SIZE.empty} aria-hidden />}
title={copy.runtimeHostWorkspaceUnavailable}
description={copy.runtimeHostWorkspaceUnavailableHelp}
/>
</VStack>
) : null}
{unavailable ? (
<Banner
status="error"
title={copy.workspaceUnavailable}
description={
<>
{copy.workspaceUnavailableHelp}
{unavailable.cwd ? (
<Text type="code" color="secondary" display="block">
{copy.taskDirectoryPath(unavailable.cwd)}
</Text>
) : null}
</>
}
endContent={
<Button
variant="ghost"
size="sm"
label={copy.retry}
isLoading={loading}
onClick={() => void load()}
/>
}
/>
) : null}
{/* Git-side failures stay errors: an unborn repository gains commits
and a failed read can succeed, so both keep their Retry. */}
{sourceError ? (
<Banner
status="error"
Expand Down
Loading