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:
Nagendhra
2026-05-11 17:59:55 -04:00
parent eb2aa84f5b
commit 89956019a0
12 changed files with 247 additions and 18 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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} />

View File

@@ -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',

View File

@@ -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',

View File

@@ -874,7 +874,7 @@ export const ja: Dict = {
'sketch.textPrompt': 'テキスト:',
'sketch.textModalTitle': 'テキストを追加',
'critiqueTheater.title': 'デザイン陪審',
'critiqueTheater.userFacingName': 'デザイン陪審',
'critiqueTheater.roleDesigner': 'デザイナー',
'critiqueTheater.roleCritic': '批評家',
'critiqueTheater.roleBrand': 'ブランド',

View File

@@ -987,7 +987,7 @@ export const ko: Dict = {
'sketch.textPrompt': '텍스트:',
'sketch.textModalTitle': '텍스트 추가',
'critiqueTheater.title': '디자인 배심',
'critiqueTheater.userFacingName': '디자인 배심',
'critiqueTheater.roleDesigner': '디자이너',
'critiqueTheater.roleCritic': '평론가',
'critiqueTheater.roleBrand': '브랜드',

View File

@@ -1059,7 +1059,7 @@ export const zhCN: Dict = {
'sketch.textPrompt': '请输入文本:',
'sketch.textModalTitle': '添加文本',
'critiqueTheater.title': '设计评审团',
'critiqueTheater.userFacingName': '设计评审团',
'critiqueTheater.roleDesigner': '设计师',
'critiqueTheater.roleCritic': '评审',
'critiqueTheater.roleBrand': '品牌',

View File

@@ -1052,7 +1052,7 @@ export const zhTW: Dict = {
'sketch.textPrompt': '請輸入文字:',
'sketch.textModalTitle': '新增文字',
'critiqueTheater.title': '設計評審團',
'critiqueTheater.userFacingName': '設計評審團',
'critiqueTheater.roleDesigner': '設計師',
'critiqueTheater.roleCritic': '評審',
'critiqueTheater.roleBrand': '品牌',

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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);
});
});