fix: keep remote recovery actions inside the project menu

Remove the duplicate recovery button beside New project and its extra layout column. Keep the collapsed failure status and the retry action on the affected project inside the dropdown.

Stage complete files as requested, including existing App snapshot regression coverage and remote runtime design updates in those files.

Validation: 29 ProjectSwitcher tests and the targeted App recovery test passed; typecheck and lint passed. Full test run timed out after 60 seconds.

Release note: Remote recovery retries now stay in the project dropdown, keeping the project header stable when a connection fails.
This commit is contained in:
mesalogo
2026-09-14 22:16:13 +08:00
parent 6c1e8105dd
commit 511f50aa63
5 changed files with 92 additions and 59 deletions
+32 -1
View File
@@ -7124,6 +7124,35 @@ describe("App", () => {
);
});
it("restores live question controls and Stop from a Main snapshot without a local run", async () => {
const requestId = "00000000-0000-4000-8000-000000000891";
const messageId = "00000000-0000-4000-8000-000000000892";
const conversationId = "00000000-0000-4000-8000-000000000893";
vi.mocked(api.conversations.list).mockResolvedValue([{
id: conversationId, projectId: project.id, title: "Recovered question", updatedAt: Date.now(),
activeRequest: {
requestId, messageId, questions: [{
requestId, type: "question", questionId: "recovered-question",
questions: [{ header: "Recovered", question: "Answer after restart?",
options: [{ label: "Continue", description: "Resume only this Session" }], multiple: false, custom: true }],
}],
},
messages: [{ id: messageId, role: "assistant", content: "Retained output", state: "streaming", createdAt: 1 }],
}]);
render(<App />);
await screen.findByText("Answer after restart?");
fireEvent.click(screen.getByRole("button", { name: "停止生成" }));
await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledWith(requestId));
fireEvent.click(screen.getByRole("radio", { name: /Continue/u }));
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
await waitFor(() => expect(api.agent.respondQuestion).toHaveBeenCalledWith(
"recovered-question", [["Continue"]],
));
expect(run).not.toHaveBeenCalled();
expect(JSON.stringify(vi.mocked(api.conversations.saveLocal).mock.calls))
.not.toContain('"activeRequest"');
});
describe("persisted terminal state convergence", () => {
let conversationsChangedListener: (() => void) | undefined;
@@ -7445,8 +7474,9 @@ describe("App", () => {
"恢复失败:无法连接 Host",
);
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
fireEvent.click(
screen.getByRole("button", {
screen.getByRole("menuitem", {
name: `重试恢复项目 ${remoteProject.name}`,
}),
);
@@ -7455,6 +7485,7 @@ describe("App", () => {
remoteProject.id,
),
);
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
expect(await screen.findByText("正在恢复网络连接…")).toBeInTheDocument();
act(() => {
+10 -2
View File
@@ -779,7 +779,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
})
it('renders an accessible failure and retries only the affected project', async () => {
it('keeps recovery retry inside the project menu while showing the collapsed failure', async () => {
await i18n.changeLanguage('en-US')
const remoteProject: AssistantProject = {
...project,
@@ -822,14 +822,22 @@ describe('ProjectSwitcher managed SSH projects', () => {
expect(screen.getAllByRole('alert')[0]).toHaveTextContent(
'Recovery failed: Host unreachable'
)
expect(screen.queryByRole('button', {
name: 'Retry recovery for project Remote project'
})).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
const menu = screen.getByRole('menu', { name: 'Current project' })
fireEvent.click(
screen.getByRole('button', {
within(menu).getByRole('menuitem', {
name: 'Retry recovery for project Remote project'
})
)
await waitFor(() =>
expect(onRetryRecovery).toHaveBeenCalledWith(remoteProject.id)
)
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Retry recovery for project Remote project')).not.toBeInTheDocument()
})
it('hides remote projects and APIs when the feature is disabled', async () => {
+1 -28
View File
@@ -1164,14 +1164,7 @@ export function ProjectSwitcher({
return (
<div className="project-switcher">
<div
className={`project-switcher__row${
activeProjectRecovery?.stage === 'failed' &&
activeProjectRecovery.retryable
? ' project-switcher__row--with-retry'
: ''
}`}
>
<div className="project-switcher__row">
<div
className="project-switcher__picker"
ref={projectPickerRef}
@@ -1346,26 +1339,6 @@ export function ProjectSwitcher({
</div>
)}
</div>
{activeProject &&
activeProjectRecovery?.stage === 'failed' &&
activeProjectRecovery.retryable &&
onRetryRecovery && (
<button
aria-label={t(
'projectSwitcher.recovery.retryNamed',
{ name: activeProjectDisplay?.name }
)}
className="icon-button project-switcher__active-retry"
disabled={
retryingRecoveryProjectId === activeProject.id
}
onClick={() => retryRecovery(activeProject.id)}
title={t('projectSwitcher.recovery.retry')}
type="button"
>
<RefreshCw aria-hidden="true" size={15} />
</button>
)}
<button
aria-label={t('projectSwitcher.selector.create')}
className="icon-button"
-8
View File
@@ -2560,10 +2560,6 @@ textarea:focus-visible {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.project-switcher__row--with-retry {
grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.project-switcher__picker {
min-width: 0;
}
@@ -2812,10 +2808,6 @@ textarea:focus-visible {
background: var(--danger-subtle);
}
.project-switcher__active-retry {
color: var(--danger);
}
.project-recovery-status {
display: flex;
min-width: 0;