mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-22 20:02:07 +08:00
fix(web): resolve P1 + P2 review feedback on Phase 9 (PR #1315)
Addresses every blocker from codex, Siri-Ray, and lefarcen. The three state-lifecycle and SSE-validation issues they also flagged inherit fixes from PR #1314's review pass that this branch now sits on top of after rebase. Real daemon kill on Interrupt (P1) - CritiqueTheaterMount now POSTs to /api/projects/:id/critique/:runId/interrupt alongside the optimistic local dispatch. Before this fix, clicking Interrupt only flipped the React state to interrupted while the daemon job kept running. The fetch is best-effort: a 404 (endpoint not wired yet, lands in Phase 15) is swallowed with a dev-mode console.warn so the UI still moves to the collapsed badge. - New fetchInterrupt test seam lets RTL assert on the URL / method and simulate the "daemon not ready yet" path. Two tests pin both: the happy URL proj-42/critique/run-abc/interrupt POSTs, and a rejected fetch still flips the UI. interruptPending reset on new run (P2) - A ref-backed effect compares the current runId against the last one we saw; when it changes, interruptPending is cleared. A user who interrupts run-1 and then triggers run-2 from the same mount now gets a fresh, enabled kill button instead of one stuck in "Interrupting…". Pinned by a new mount test. Escape keybind scope (P2) - InterruptButton now checks the keydown target. Escape inside an input, textarea, select, or contenteditable element is ignored (and any ancestor of those via closest() is treated the same way). Body-level focus still fires the keybind so the Theater area's affordance keeps working. Four new tests cover textarea, input, contenteditable, and the body-focus positive case. userFacingName i18n key (P2) - The spec at specs/current/critique-theater.md:6 mandates a single critiqueTheater.userFacingName key so the "Design Jury" label can be renamed without touching code. Phase 8 introduced critiqueTheater.title by mistake; renamed across types.ts, en.ts, zh-CN.ts, de.ts, ja.ts, ko.ts, zh-TW.ts, and the lone consumer TheaterStage.tsx. The locale alignment test stays green. Validated - pnpm guard clean - pnpm --filter @open-design/web typecheck clean - Theater suite: 14 files, 112 tests (was 101 before, +11 new for the Phase 9 review pass: 3 mount + 4 InterruptButton focus scope; the rest were already in #1314's review fix). - tests/i18n/locales.test.ts 5 of 5 across 18 locales.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useCritiqueStream } from './hooks/useCritiqueStream';
|
||||
import { TheaterStage } from './TheaterStage';
|
||||
@@ -17,6 +17,16 @@ interface Props {
|
||||
? F
|
||||
: never
|
||||
: never;
|
||||
/**
|
||||
* Test seam for the kill request. Production callers pass nothing
|
||||
* and we use platform `fetch`; tests inject a stub to capture the
|
||||
* URL / method and resolve with a synthetic Response. Returning a
|
||||
* rejected promise simulates a daemon that has not landed the
|
||||
* endpoint yet — the optimistic local dispatch still fires so the
|
||||
* UI moves to `interrupted`, and the warning surfaces on the
|
||||
* developer console rather than tearing the React tree.
|
||||
*/
|
||||
fetchInterrupt?: (url: string, init: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,11 +37,16 @@ interface Props {
|
||||
* settles. Idle returns `null` so projects without a live run stay
|
||||
* visually unchanged.
|
||||
*
|
||||
* The `enabled` prop is the M1 settings toggle — when false the hook
|
||||
* The `enabled` prop is the M1 settings toggle: when false the hook
|
||||
* tears down its connection and the mount renders nothing, regardless
|
||||
* of any in-flight runs the user opened previously.
|
||||
*/
|
||||
export function CritiqueTheaterMount({ projectId, enabled, connectionFactory }: Props) {
|
||||
export function CritiqueTheaterMount({
|
||||
projectId,
|
||||
enabled,
|
||||
connectionFactory,
|
||||
fetchInterrupt,
|
||||
}: Props) {
|
||||
const options = useMemo(
|
||||
() => (connectionFactory ? { connectionFactory } : {}),
|
||||
[connectionFactory],
|
||||
@@ -39,21 +54,54 @@ export function CritiqueTheaterMount({ projectId, enabled, connectionFactory }:
|
||||
const { state, dispatch } = useCritiqueStream(projectId, enabled, options);
|
||||
const [interruptPending, setInterruptPending] = useState(false);
|
||||
|
||||
// Reset `interruptPending` whenever the runId changes so a fresh
|
||||
// run after a prior interrupt does not inherit a stuck button.
|
||||
// Codex P2 on PR #1315: the previous revision left `interruptPending`
|
||||
// true forever once clicked.
|
||||
const lastRunIdRef = useRef<string | null>(null);
|
||||
const currentRunId = state.phase === 'idle' ? null : state.runId;
|
||||
useEffect(() => {
|
||||
if (lastRunIdRef.current !== currentRunId) {
|
||||
lastRunIdRef.current = currentRunId;
|
||||
setInterruptPending(false);
|
||||
}
|
||||
}, [currentRunId]);
|
||||
|
||||
const onInterrupt = useCallback(() => {
|
||||
if (interruptPending) return;
|
||||
if (state.phase !== 'running') return;
|
||||
if (!projectId) return;
|
||||
setInterruptPending(true);
|
||||
// Synthesize an interrupted action locally so the UI reflects the
|
||||
// user's intent immediately. The real kill request to the daemon
|
||||
// lands in Phase 15's rollout wiring; for now the optimistic
|
||||
// dispatch keeps the surface honest about user intent.
|
||||
|
||||
// Lefarcen + codex P1 on PR #1315: the previous revision did the
|
||||
// optimistic local dispatch only, so the daemon-side run kept
|
||||
// executing while the UI ignored the real terminal event. Fire
|
||||
// the daemon kill request alongside the optimistic dispatch.
|
||||
// Daemon contract: `POST /api/projects/:id/critique/:runId/interrupt`
|
||||
// returns 204 on success, 404 if the endpoint has not been wired
|
||||
// up yet (Phase 15). Either response is treated as a best-effort
|
||||
// signal: the UI moves to `interrupted` because the user asked
|
||||
// for it; any real outcome the daemon emits later is dropped
|
||||
// because terminal phases are sticky in the reducer.
|
||||
const fetcher = fetchInterrupt ?? ((url, init) => fetch(url, init));
|
||||
const url = `/api/projects/${encodeURIComponent(projectId)}/critique/${encodeURIComponent(state.runId)}/interrupt`;
|
||||
fetcher(url, { method: 'POST' }).catch((err) => {
|
||||
if (
|
||||
typeof process !== 'undefined'
|
||||
&& process.env?.NODE_ENV === 'development'
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[critique-theater] interrupt request failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: 'interrupted',
|
||||
runId: state.runId,
|
||||
bestRound: bestRoundOf(state),
|
||||
composite: bestCompositeOf(state),
|
||||
});
|
||||
}, [interruptPending, state, dispatch]);
|
||||
}, [interruptPending, state, dispatch, projectId, fetchInterrupt]);
|
||||
|
||||
if (!enabled) return null;
|
||||
if (state.phase === 'idle') return null;
|
||||
|
||||
@@ -26,6 +26,24 @@ export function InterruptButton({ pending = false, done = false, onInterrupt }:
|
||||
const handler = (evt: KeyboardEvent) => {
|
||||
if (evt.key !== 'Escape') return;
|
||||
if (pending) return;
|
||||
// Lefarcen P2 on PR #1315: the previous revision fired the
|
||||
// interrupt regardless of focus, so pressing Escape inside the
|
||||
// prompt textarea, a search box, a select, or any
|
||||
// contenteditable would cancel an in-flight critique by
|
||||
// accident. Scope the handler to events that originate outside
|
||||
// text-entry surfaces so the keybind only fires from the
|
||||
// Theater area (or generic body focus).
|
||||
const target = evt.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (target.isContentEditable) return;
|
||||
if (typeof target.closest === 'function') {
|
||||
if (target.closest('input, textarea, select, [contenteditable="true"]')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onInterrupt();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
|
||||
@@ -41,11 +41,11 @@ export function TheaterStage({ state, onInterrupt, interruptPending = false }: P
|
||||
<section
|
||||
className="theater-stage"
|
||||
role="region"
|
||||
aria-label={t('critiqueTheater.title')}
|
||||
aria-label={t('critiqueTheater.userFacingName')}
|
||||
data-phase="running"
|
||||
>
|
||||
<header className="theater-stage-head">
|
||||
<h3 className="theater-stage-title">{t('critiqueTheater.title')}</h3>
|
||||
<h3 className="theater-stage-title">{t('critiqueTheater.userFacingName')}</h3>
|
||||
<InterruptButton pending={interruptPending} onInterrupt={onInterrupt} />
|
||||
</header>
|
||||
<ScoreTicker rounds={rounds} threshold={config.threshold} scale={config.scale} />
|
||||
|
||||
@@ -875,7 +875,7 @@ export const de: Dict = {
|
||||
'sketch.textPrompt': 'Text:',
|
||||
'sketch.textModalTitle': 'Text hinzufügen',
|
||||
|
||||
'critiqueTheater.title': 'Design-Jury',
|
||||
'critiqueTheater.userFacingName': 'Design-Jury',
|
||||
'critiqueTheater.roleDesigner': 'Designer',
|
||||
'critiqueTheater.roleCritic': 'Kritiker',
|
||||
'critiqueTheater.roleBrand': 'Marke',
|
||||
|
||||
@@ -1089,7 +1089,7 @@ export const en: Dict = {
|
||||
'sketch.textPrompt': 'Text:',
|
||||
'sketch.textModalTitle': 'Add text',
|
||||
|
||||
'critiqueTheater.title': 'Design Jury',
|
||||
'critiqueTheater.userFacingName': 'Design Jury',
|
||||
'critiqueTheater.roleDesigner': 'Designer',
|
||||
'critiqueTheater.roleCritic': 'Critic',
|
||||
'critiqueTheater.roleBrand': 'Brand',
|
||||
|
||||
@@ -874,7 +874,7 @@ export const ja: Dict = {
|
||||
'sketch.textPrompt': 'テキスト:',
|
||||
'sketch.textModalTitle': 'テキストを追加',
|
||||
|
||||
'critiqueTheater.title': 'デザイン陪審',
|
||||
'critiqueTheater.userFacingName': 'デザイン陪審',
|
||||
'critiqueTheater.roleDesigner': 'デザイナー',
|
||||
'critiqueTheater.roleCritic': '批評家',
|
||||
'critiqueTheater.roleBrand': 'ブランド',
|
||||
|
||||
@@ -987,7 +987,7 @@ export const ko: Dict = {
|
||||
'sketch.textPrompt': '텍스트:',
|
||||
'sketch.textModalTitle': '텍스트 추가',
|
||||
|
||||
'critiqueTheater.title': '디자인 배심',
|
||||
'critiqueTheater.userFacingName': '디자인 배심',
|
||||
'critiqueTheater.roleDesigner': '디자이너',
|
||||
'critiqueTheater.roleCritic': '평론가',
|
||||
'critiqueTheater.roleBrand': '브랜드',
|
||||
|
||||
@@ -1059,7 +1059,7 @@ export const zhCN: Dict = {
|
||||
'sketch.textPrompt': '请输入文本:',
|
||||
'sketch.textModalTitle': '添加文本',
|
||||
|
||||
'critiqueTheater.title': '设计评审团',
|
||||
'critiqueTheater.userFacingName': '设计评审团',
|
||||
'critiqueTheater.roleDesigner': '设计师',
|
||||
'critiqueTheater.roleCritic': '评审',
|
||||
'critiqueTheater.roleBrand': '品牌',
|
||||
|
||||
@@ -1052,7 +1052,7 @@ export const zhTW: Dict = {
|
||||
'sketch.textPrompt': '請輸入文字:',
|
||||
'sketch.textModalTitle': '新增文字',
|
||||
|
||||
'critiqueTheater.title': '設計評審團',
|
||||
'critiqueTheater.userFacingName': '設計評審團',
|
||||
'critiqueTheater.roleDesigner': '設計師',
|
||||
'critiqueTheater.roleCritic': '評審',
|
||||
'critiqueTheater.roleBrand': '品牌',
|
||||
|
||||
@@ -1470,7 +1470,7 @@ export interface Dict {
|
||||
'sketch.textPrompt': string;
|
||||
'sketch.textModalTitle': string;
|
||||
// Critique Theater (Phase 8 components; Phase 9 fills non-English locales)
|
||||
'critiqueTheater.title': string;
|
||||
'critiqueTheater.userFacingName': string;
|
||||
'critiqueTheater.roleDesigner': string;
|
||||
'critiqueTheater.roleCritic': string;
|
||||
'critiqueTheater.roleBrand': string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CritiqueTheaterMount } from '../../../src/components/Theater/CritiqueTheaterMount';
|
||||
import type { CritiqueAction } from '../../../src/components/Theater/state/reducer';
|
||||
@@ -111,4 +111,121 @@ describe('<CritiqueTheaterMount> (Phase 9.1)', () => {
|
||||
);
|
||||
expect(handles[0]!.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('POSTs to the daemon interrupt endpoint on Interrupt click (PR #1315 review)', () => {
|
||||
// Lefarcen + codex P1: the previous revision did the optimistic
|
||||
// local dispatch only, so the daemon-side run kept running while
|
||||
// the UI ignored the real terminal event. Fire the kill request
|
||||
// alongside the optimistic dispatch.
|
||||
const { factory, handles } = makeFactory();
|
||||
const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fetchInterrupt = vi.fn(async (url: string, init: RequestInit) => {
|
||||
fetchCalls.push({ url, init });
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
render(
|
||||
<CritiqueTheaterMount
|
||||
projectId="proj-42"
|
||||
enabled
|
||||
connectionFactory={factory}
|
||||
fetchInterrupt={fetchInterrupt}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
handles[0]!.send({
|
||||
type: 'run_started',
|
||||
runId: 'run-abc',
|
||||
protocolVersion: 1,
|
||||
cast: ['critic'],
|
||||
maxRounds: 3,
|
||||
threshold: 8,
|
||||
scale: 10,
|
||||
});
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Interrupt' }));
|
||||
|
||||
expect(fetchInterrupt).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCalls[0]!.url).toBe(
|
||||
'/api/projects/proj-42/critique/run-abc/interrupt',
|
||||
);
|
||||
expect(fetchCalls[0]!.init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('swallows a rejected interrupt fetch and still moves the UI to interrupted', () => {
|
||||
// If the daemon endpoint has not landed yet (Phase 15), the
|
||||
// user's click should still flip the UI; the warning surfaces
|
||||
// on the dev console rather than tearing the React tree.
|
||||
const { factory, handles } = makeFactory();
|
||||
const fetchInterrupt = vi.fn(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
render(
|
||||
<CritiqueTheaterMount
|
||||
projectId="proj-1"
|
||||
enabled
|
||||
connectionFactory={factory}
|
||||
fetchInterrupt={fetchInterrupt}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
handles[0]!.send({
|
||||
type: 'run_started',
|
||||
runId: 'run-abc',
|
||||
protocolVersion: 1,
|
||||
cast: ['critic'],
|
||||
maxRounds: 3,
|
||||
threshold: 8,
|
||||
scale: 10,
|
||||
});
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Interrupt' }));
|
||||
// Optimistic dispatch already fired so the collapsed surface
|
||||
// mounts in place of the live stage.
|
||||
expect(screen.getByRole('status').getAttribute('data-phase')).toBe('interrupted');
|
||||
});
|
||||
|
||||
it('resets interruptPending when a fresh run starts after an interrupt (codex P2 on PR #1315)', () => {
|
||||
// Previously `interruptPending` stayed true forever once clicked,
|
||||
// so a second run on the same mount would render the kill
|
||||
// button stuck in the "Interrupting…" state.
|
||||
const { factory, handles } = makeFactory();
|
||||
render(
|
||||
<CritiqueTheaterMount
|
||||
projectId="proj-1"
|
||||
enabled
|
||||
connectionFactory={factory}
|
||||
fetchInterrupt={vi.fn(async () => new Response(null, { status: 204 }))}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
handles[0]!.send({
|
||||
type: 'run_started',
|
||||
runId: 'run-1',
|
||||
protocolVersion: 1,
|
||||
cast: ['critic'],
|
||||
maxRounds: 3,
|
||||
threshold: 8,
|
||||
scale: 10,
|
||||
});
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Interrupt' }));
|
||||
expect(screen.getByRole('status').getAttribute('data-phase')).toBe('interrupted');
|
||||
|
||||
// Daemon emits a fresh run_started for the next rerun. The
|
||||
// collapsed badge should give way to a live stage with a fresh
|
||||
// Interrupt button that is NOT pending.
|
||||
act(() => {
|
||||
handles[0]!.send({
|
||||
type: 'run_started',
|
||||
runId: 'run-2',
|
||||
protocolVersion: 1,
|
||||
cast: ['critic'],
|
||||
maxRounds: 3,
|
||||
threshold: 8,
|
||||
scale: 10,
|
||||
});
|
||||
});
|
||||
const btn = screen.getByRole('button', { name: 'Interrupt' }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,4 +44,50 @@ describe('<InterruptButton> (Phase 8)', () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onInterrupt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores Escape when focus is inside a textarea (lefarcen P2 on PR #1315)', () => {
|
||||
// Previously the Esc handler fired regardless of focus, so
|
||||
// pressing Escape while typing in the prompt textarea or any
|
||||
// other text-entry field would cancel the in-flight critique by
|
||||
// accident.
|
||||
const onInterrupt = vi.fn();
|
||||
const textarea = document.createElement('textarea');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
render(<InterruptButton onInterrupt={onInterrupt} />);
|
||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||
expect(onInterrupt).not.toHaveBeenCalled();
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
|
||||
it('ignores Escape when focus is inside an input', () => {
|
||||
const onInterrupt = vi.fn();
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
render(<InterruptButton onInterrupt={onInterrupt} />);
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(onInterrupt).not.toHaveBeenCalled();
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it('ignores Escape when focus is inside a contenteditable surface', () => {
|
||||
const onInterrupt = vi.fn();
|
||||
const editor = document.createElement('div');
|
||||
editor.setAttribute('contenteditable', 'true');
|
||||
document.body.appendChild(editor);
|
||||
editor.focus();
|
||||
render(<InterruptButton onInterrupt={onInterrupt} />);
|
||||
fireEvent.keyDown(editor, { key: 'Escape' });
|
||||
expect(onInterrupt).not.toHaveBeenCalled();
|
||||
document.body.removeChild(editor);
|
||||
});
|
||||
|
||||
it('still fires Escape when focus is on a non-text element', () => {
|
||||
const onInterrupt = vi.fn();
|
||||
render(<InterruptButton onInterrupt={onInterrupt} />);
|
||||
// Body-level focus (no text input) still triggers the keybind.
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
expect(onInterrupt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user