fix(web): make hand-off no-editors fallback perform a real reveal (#2494)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 3s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 2s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped

* fix(web): make hand-off no-editors fallback perform a real reveal

The Finder/Explorer/File Manager fallback button was only calling an
optional onRequestRevealInFinder prop that the actual caller never
passes, so the surface advertised an action it never performed.

finder, explorer, and file-manager are real entries in the daemon's
open-in catalogue (open / explorer / xdg-open), so route the fallback
through openProjectInEditor(projectId, fallbackId) for a genuine
reveal. Keep the renderer reveal bridge as a secondary fallback if
the daemon spawn fails, and disable the button while busy so a double
click can't queue two reveals.

Adjacent: PreviewDrawOverlay's Send-while-streaming behavior is
intentional (sending is queued downstream, not blocked), and the
button already carries sendDisabledReason as its tooltip. Cover that
contract with a regression test so a future change can't silently
re-disable the control or drop the localized reason.

Scope note: the i18n hand-off key migration that previously rode on
this branch landed on main via a different key set, so this PR is
narrowed to just the fallback wire-up and the two regression tests.

* fix(web): surface daemon spawn failure inline in zero-editors fallback

The zero-editors HandoffButton fallback called setError() on a rejected
openProjectInEditor but returned only the <button>, so the error never
rendered. Production callers (ProjectView) mount the component without
onRequestRevealInFinder, so a daemon spawn failure became a silent no-op
— exactly the failure mode the PR was meant to cover.

Wrap the solo button in a handoff-wrap container and render the error
inline next to it. Adds a regression test for the rejected-spawn path.

* fix(web): align preview draw send-disabled test

* fix(web): show handoff fallback for zero editors

---------

Co-authored-by: nicejames <nicejames@gmail.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
This commit is contained in:
leessju 2026-05-30 15:34:12 +09:00 committed by GitHub
parent b76e7196db
commit cfde84b038
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 147 additions and 12 deletions

View file

@ -126,26 +126,53 @@ export function HandoffButton({ projectId, onRequestRevealInFinder }: Props) {
}
}
if (!loaded || (available.length === 0 && unavailable.length === 0)) {
if (!loaded) {
return null;
}
// No detected editors at all — render a Finder/Explorer/File-Manager
// single-button fallback so the surface is never blank.
// No available editors — render a Finder/Explorer/File-Manager single-button
// fallback so the surface is never blank, including the true zero-editor
// response where the daemon reports `editors: []`.
if (available.length === 0) {
const fallbackLabel = platform === 'win32' ? 'Explorer' : platform === 'linux' ? 'File Manager' : 'Finder';
const fallbackId: HostEditorId =
platform === 'win32' ? 'explorer' : platform === 'linux' ? 'file-manager' : 'finder';
// Wrap the solo button so a daemon spawn failure can surface an
// inline error next to it — without this, ProjectView's
// `<HandoffButton projectId={…} />` (no reveal callback) turns a
// rejected `openProjectInEditor` into a silent no-op.
return (
<button
type="button"
className="handoff-trigger handoff-trigger--solo"
title={t('handoff.fallbackTitle', { target: fallbackLabel })}
onClick={() => onRequestRevealInFinder?.()}
>
<EditorIcon editorId={fallbackId} size={20} />
<span className="handoff-trigger-label">{fallbackLabel}</span>
</button>
<div className="handoff-wrap handoff-wrap--solo" data-testid="handoff-wrap">
<button
type="button"
className="handoff-trigger handoff-trigger--solo"
title={t('handoff.fallbackTitle', { target: fallbackLabel })}
disabled={busy === fallbackId}
onClick={() => {
// The fallback opens the project folder in the OS file manager.
// finder / explorer / file-manager are real entries in the daemon's
// open-in catalogue (open / explorer / xdg-open), so this performs a
// genuine reveal rather than a no-op; the renderer reveal bridge is a
// secondary fallback if the daemon spawn fails.
setError(null);
setBusy(fallbackId);
void openProjectInEditor(projectId, fallbackId)
.catch((err) => {
setError(err instanceof Error ? err.message : String(err));
onRequestRevealInFinder?.();
})
.finally(() => setBusy(null));
}}
>
<EditorIcon editorId={fallbackId} size={20} />
<span className="handoff-trigger-label">{fallbackLabel}</span>
</button>
{error ? (
<div className="handoff-menu-error" role="alert" data-testid="handoff-fallback-error">
{error}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,72 @@
// @vitest-environment jsdom
// Regression for the zero-editors fallback: when no editor is detected, the
// fallback button must perform a real reveal (open the project folder via the
// daemon's open-in catalogue: finder / explorer / file-manager) rather than a
// no-op that advertises an action it never runs.
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HandoffButton } from '../../src/components/HandoffButton';
import { I18nProvider } from '../../src/i18n';
import type { HostEditorsResponse } from '@open-design/contracts';
const fetchHostEditors = vi.fn<() => Promise<HostEditorsResponse>>();
const openProjectInEditor = vi.fn();
vi.mock('../../src/providers/registry', () => ({
fetchHostEditors: () => fetchHostEditors(),
openProjectInEditor: (...args: unknown[]) => openProjectInEditor(...args),
}));
afterEach(() => {
cleanup();
fetchHostEditors.mockReset();
openProjectInEditor.mockReset();
});
describe('HandoffButton zero-editors fallback', () => {
it('opens the project folder in the OS file manager via the daemon', async () => {
fetchHostEditors.mockResolvedValue({
platform: 'darwin',
editors: [],
});
openProjectInEditor.mockResolvedValue(undefined);
render(
<I18nProvider initial="en">
<HandoffButton projectId="p1" />
</I18nProvider>,
);
const fallback = (await screen.findByText('Finder')).closest('button') as HTMLButtonElement;
fireEvent.click(fallback);
await waitFor(() => expect(openProjectInEditor).toHaveBeenCalledWith('p1', 'finder'));
});
it('surfaces a daemon spawn failure inline so the fallback is not a silent no-op', async () => {
// The production caller (`ProjectView`) mounts `<HandoffButton projectId={…} />`
// with no `onRequestRevealInFinder` callback, so a rejected
// `openProjectInEditor` would otherwise leave users with a CTA that
// advertises Finder/Explorer/File Manager but does nothing visible.
fetchHostEditors.mockResolvedValue({
platform: 'darwin',
editors: [],
});
openProjectInEditor.mockRejectedValue(new Error('daemon refused: ENOENT'));
render(
<I18nProvider initial="en">
<HandoffButton projectId="p1" />
</I18nProvider>,
);
const fallback = (await screen.findByText('Finder')).closest('button') as HTMLButtonElement;
fireEvent.click(fallback);
const errorEl = await screen.findByTestId('handoff-fallback-error');
expect(errorEl.textContent).toContain('daemon refused: ENOENT');
});
});

View file

@ -0,0 +1,36 @@
// @vitest-environment jsdom
// Regression for the streaming-state localization: while sending is disabled
// (e.g. a run is in flight), the Send control stays rendered with the
// localized reason as its tooltip so the message reaches the DOM instead of
// being dropped. Queue remains available for staging the note downstream.
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { PreviewDrawOverlay } from '../../src/components/PreviewDrawOverlay';
afterEach(() => {
cleanup();
});
describe('PreviewDrawOverlay send disabled (streaming) localization', () => {
it('surfaces the localized reason on the Send tooltip while keeping Queue operable', () => {
render(
<PreviewDrawOverlay active sendDisabled sendDisabledReason="Task running">
<div data-testid="content" />
</PreviewDrawOverlay>,
);
const note = document.querySelector('.preview-draw-note-input') as HTMLInputElement;
fireEvent.change(note, { target: { value: 'looks good' } });
const send = screen.getByRole('button', { name: 'Send' });
const queue = screen.getByRole('button', { name: 'Queue' });
// The localized reason reaches the DOM as the button's tooltip...
expect(send.getAttribute('title')).toBe('Task running');
// ...and Queue stays operable so the mark can be staged for the next turn.
expect((send as HTMLButtonElement).disabled).toBe(true);
expect((queue as HTMLButtonElement).disabled).toBe(false);
});
});