From cfde84b038d704275ad207c3a2ddfdef3cb29978 Mon Sep 17 00:00:00 2001 From: leessju <40141791+leessju@users.noreply.github.com> Date: Sat, 30 May 2026 15:34:12 +0900 Subject: [PATCH] fix(web): make hand-off no-editors fallback perform a real reveal (#2494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 +
+ + {error ? ( +
+ {error} +
+ ) : null} +
); } diff --git a/apps/web/tests/components/HandoffButton.fallback-reveal.test.tsx b/apps/web/tests/components/HandoffButton.fallback-reveal.test.tsx new file mode 100644 index 000000000..4a694476e --- /dev/null +++ b/apps/web/tests/components/HandoffButton.fallback-reveal.test.tsx @@ -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>(); +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( + + + , + ); + + 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 `` + // 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( + + + , + ); + + 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'); + }); +}); diff --git a/apps/web/tests/components/PreviewDrawOverlay.send-disabled.test.tsx b/apps/web/tests/components/PreviewDrawOverlay.send-disabled.test.tsx new file mode 100644 index 000000000..c4a07373c --- /dev/null +++ b/apps/web/tests/components/PreviewDrawOverlay.send-disabled.test.tsx @@ -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( + +
+ , + ); + + 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); + }); +});