diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a17d3d6d..69b42ba47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,7 @@ jobs: scripts/__tests__/releaseWorkflowContracts.test.ts convex/domains/redesign/chatRuns.responseShape.test.ts src/features/redesign/components/UniversalComposer.test.tsx + src/features/redesign/surfaces/ScratchnodeEventsSurface.test.tsx scratchnode-launch-gates: name: ScratchNode launch gates diff --git a/CHANGELOG/pages/redesign-chat.md b/CHANGELOG/pages/redesign-chat.md index e7ff9beca..4dd19adca 100644 --- a/CHANGELOG/pages/redesign-chat.md +++ b/CHANGELOG/pages/redesign-chat.md @@ -3,6 +3,48 @@ Append-only lane for the public redesign chat, reproducible answer receipts, and their transition into an authenticated live conversation. Newest entries first. +## 2026-07-17 - Resolve the four hardening residuals from the production audit + +The 2026-07-16 audit cycle left four filed residuals (#567-#570); all four land here. + +1. **Red tests CI could not see (#567)** - three ScratchnodeEventsSurface tests failed on + clean main (`TypeError: reading 'product'`) because the test's api mock predated + ImportRecapButton's `domains.product.scratchnodeImport` query, and the runtime-smoke + allowlist never ran the file. The api mock is now a Proxy that degrades unknown + function refs to unresolved queries, the missing `useMutation` mock is added, two + scenarios cover the import affordance's published/unresolved gates, and the file joins + the CI allowlist. +2. **Timer-only cancel guard (#568)** - Stop and submit are both always rendered: Stop in + a reserved `visibility:hidden` slot, submit staying at identical coordinates while + streaming (disabled). A double-click's second click lands on an inert control at ANY + user double-click interval; the 400ms arming stays as defense in depth. +3. **Shape detector gap (#569)** - "in one sentence", "a single paragraph", and + "under N words" are now detected, instructed via an exhaustive + `responseShapeSystemInstructions` switch, and deterministically enforced (first-sentence + extraction, prose collapse, sentence-accumulating word budget). Honesty survives + compaction: unsupported runs carry `Source needed:` within the requested shape. + JSON/table stay model-side deliberately - the render path is markdown prose. +4. **No computed-geometry regression capture (#570)** - one-flow-regression (Tier B, + per-PR) gains a 390x844 test asserting `.rd-shell__main` resolves to ONE grid track and + `main#main-content` stays >300px wide - the audit's 70px collapse was invisible to both + the CSS-source string guard and the document-overflow boolean. + +**PR / canonical main commit**: `PENDING #NNN MAIN SHA / FINAL QA`. + +**Evidence state**: +- Source: `pending` +- Checks: `npx tsc --noEmit` -> 0 errors. Full runtime-smoke allowlist locally -> + 312 passed / 25 skipped (integration skips without env keys, matching CI). Adjacent + guards (ChatResponseShape, ChatRunLifecycle, ChatContinuation, sourceVerificationPolicy) + -> 12 passed. +- Visual proof: `not recorded` - behavior changes are runtime/composer mechanics. +- Preview: Tier B one-flow-regression includes the new 390px geometry test on this PR. +- Production live: `not recorded` at entry time. + +**Author**: Homen Shum + Claude Fable 5. +**Touches**: [components/fast-agent-panel.md](../components/fast-agent-panel.md) is NOT +touched - the composer change is redesign-chat's UniversalComposer, not FastAgentPanel. + ## 2026-07-17 - Ground "best source" superlatives on their own citation `applyDeterministicResponsePolicy` only rewrote unsupported "best/strongest source|claim" @@ -22,10 +64,10 @@ constant used by both the sanitizer and the gate so they cannot disagree. This closes the residual left open by the 2026-07-16 audit follow-up (#565 fixed four of the five P1s; this is the fifth). Tracked as #566. -**PR / canonical main commit**: `PENDING #NNN MAIN SHA / FINAL QA`. +**PR / canonical main commit**: `#571` / `6198dc39`. **Evidence state**: -- Source: `pending` +- Source: `merged` - Checks: `npx tsc -p convex --noEmit --pretty false` -> 0 errors. `npx vitest run convex/domains/redesign/chatRuns.responseShape.test.ts` -> 16 passed (3 new: grounded citation kept, cached-label citation rewritten, uncited superlative rewritten). Gate @@ -63,10 +105,10 @@ names in UI / provider names appear only in the trace" - the opposite of the dis contract shipped in #550 - and pinned `DEFAULT_TIERS` to the runtime's `modelForTier` with a parity test, since the two were hand-maintained mirrors with no test binding them. -**PR / canonical main commit**: `PENDING #NNN MAIN SHA / FINAL QA`. +**PR / canonical main commit**: `#565` / `b2d12ae0`. **Evidence state**: -- Source: `pending` +- Source: `merged` - Checks: `npx tsc --noEmit --pretty false` -> 0 errors. `npx vitest run convex/domains/redesign/chatRuns.responseShape.test.ts src/features/redesign/components/UniversalComposer.test.tsx` -> 16 passed. Fix reverted diff --git a/convex/domains/redesign/chatRuns.responseShape.test.ts b/convex/domains/redesign/chatRuns.responseShape.test.ts index ec3f7beb5..edb24f3a2 100644 --- a/convex/domains/redesign/chatRuns.responseShape.test.ts +++ b/convex/domains/redesign/chatRuns.responseShape.test.ts @@ -63,6 +63,91 @@ describe("redesign chat runtime response policy", () => { } }); + // Issue #569 — every explicit shape beyond title/bullets used to fall + // through to the five-section memo. These scenarios model an analyst + // pasting exact-output constraints into the composer. + it("detects single-sentence, single-paragraph, and word-limit requests", () => { + expect(detectRequestedResponseShape("Summarize the brief in one sentence.")).toEqual({ kind: "sentence" }); + expect(detectRequestedResponseShape("Give me just one sentence on the risk.")).toEqual({ kind: "sentence" }); + expect(detectRequestedResponseShape("Write a one-sentence summary.")).toEqual({ kind: "sentence" }); + expect(detectRequestedResponseShape("Explain the funding round in a single paragraph.")).toEqual({ kind: "paragraph" }); + expect(detectRequestedResponseShape("One-paragraph answer please.")).toEqual({ kind: "paragraph" }); + expect(detectRequestedResponseShape("Summarize in under 50 words.")).toEqual({ kind: "word_limit", limit: 50 }); + expect(detectRequestedResponseShape("Answer in 30 words or fewer.")).toEqual({ kind: "word_limit", limit: 30 }); + expect(detectRequestedResponseShape("No more than 80 words.")).toEqual({ kind: "word_limit", limit: 80 }); + }); + + it("keeps memo for incidental or degenerate shape phrasing", () => { + // The memo template itself says "one sentence" per heading — user prose + // that merely mentions sentences/paragraphs must not flip the shape. + expect(detectRequestedResponseShape("The first sentence of their pitch is misleading.")).toEqual({ kind: "memo" }); + expect(detectRequestedResponseShape("Compare the opening paragraph across both reports.")).toEqual({ kind: "memo" }); + // Degenerate or absurd limits never bind. + expect(detectRequestedResponseShape("Summarize in under 2 words.")).toEqual({ kind: "memo" }); + expect(detectRequestedResponseShape("Summarize in under 90000 words.")).toEqual({ kind: "memo" }); + // Bullets outrank a word limit when both appear. + expect( + detectRequestedResponseShape("Give me exactly 3 bullets, under 50 words."), + ).toEqual({ kind: "bullets", count: 3 }); + }); + + it("returns exactly one sentence while omitting memo-only fields", () => { + // idx binds the [1] marker to the URL-backed row so the #571 superlative + // gate recognizes the claim as grounded and leaves the sentence intact. + const shaped = applyDeterministicResponsePolicy( + "Summarize the brief in one sentence.", + memo, + [{ idx: 1, source: "https://example.com/acme", quote: "Acme launched a new product." }], + ); + expect(shaped.shortAnswer).toBe("Acme is the strongest supported claim in the current packet. [1]"); + expect(shaped.whyItMatters).toBe(""); + expect(shaped.risks).toEqual([]); + expect(shaped.nextAction).toBe(""); + }); + + it("collapses the memo into one paragraph and keeps the source-needed limitation inline when unsupported", () => { + const supported = applyDeterministicResponsePolicy( + "Explain it in a single paragraph.", + memo, + [{ source: "https://example.com/acme", quote: "Acme launched a new product." }], + ); + expect(supported.shortAnswer).not.toContain("\n"); + expect(supported.whyItMatters).toBe(""); + + const unsupported = applyDeterministicResponsePolicy( + "Explain it in a single paragraph.", + memo, + [{ source: "Setup" }], + ); + // Honesty survives the compact shape: the limitation rides inside the + // paragraph because compact renders hide the risks section entirely. + expect(unsupported.shortAnswer).toContain("Source needed"); + expect(unsupported.risks).toEqual([]); + }); + + it("enforces an explicit word limit deterministically, including the honesty prefix", () => { + const longMemo = { + ...memo, + shortAnswer: + "Acme closed the round. The filing lists twelve investors across three continents and two follow-on commitments.", + }; + const shaped = applyDeterministicResponsePolicy( + "Summarize in under 8 words.", + longMemo, + [{ source: "https://example.com/acme", quote: "Acme closed the round." }], + ); + expect(shaped.shortAnswer.split(/\s+/).length).toBeLessThanOrEqual(8); + expect(shaped.whyItMatters).toBe(""); + + const unsupported = applyDeterministicResponsePolicy( + "Summarize in under 8 words.", + longMemo, + [{ source: "Setup" }], + ); + expect(unsupported.shortAnswer.startsWith("Source needed:")).toBe(true); + expect(unsupported.shortAnswer.split(/\s+/).length).toBeLessThanOrEqual(8); + }); + it("does not mistake incidental prose about titles for a shape request", () => { expect( detectRequestedResponseShape("Summarize the report and explain why its title is misleading."), diff --git a/convex/domains/redesign/chatRuns.ts b/convex/domains/redesign/chatRuns.ts index 45f3c63c2..fe713f106 100644 --- a/convex/domains/redesign/chatRuns.ts +++ b/convex/domains/redesign/chatRuns.ts @@ -817,7 +817,10 @@ export interface ParsedMemo { export type RequestedResponseShape = | { kind: "memo" } | { kind: "title_only" } - | { kind: "bullets"; count: number }; + | { kind: "bullets"; count: number } + | { kind: "sentence" } + | { kind: "paragraph" } + | { kind: "word_limit"; limit: number }; const RESPONSE_COUNT_WORDS: Record = { one: 1, @@ -862,6 +865,39 @@ const TITLE_ONLY_PATTERNS: RegExp[] = [ ), ]; +// "one sentence" / "one paragraph" requests. The unit is parameterized so the +// two shapes cannot drift apart the way the title determiners once did (#565). +function singleUnitPatterns(unit: string): RegExp[] { + return [ + new RegExp(String.raw`\b(?:in|as)\s+(?:exactly\s+)?(?:one|a\s+single|1)\s+${unit}\b`), + new RegExp(String.raw`\b(?:only|just)\s+(?:one|a\s+single|1)\s+${unit}\b`), + new RegExp(String.raw`\bone[- ]${unit}\s+(?:answer|summary|response|reply)\b`), + ]; +} +const SINGLE_SENTENCE_PATTERNS = singleUnitPatterns("sentence"); +const SINGLE_PARAGRAPH_PATTERNS = singleUnitPatterns("paragraph"); + +// "under 50 words" and equivalents. Bounds reject degenerate limits ("under 2 +// words") and absurd ones that would never bind ("under 90000 words"). +const WORD_LIMIT_MIN = 5; +const WORD_LIMIT_MAX = 400; +const WORD_LIMIT_PATTERNS: RegExp[] = [ + /\b(?:under|within|at most|no more than|max(?:imum)?(?: of)?|fewer than|less than)\s+(\d+)\s+words\b/, + /\bin\s+(\d+)\s+words\s+or\s+(?:less|fewer)\b/, +]; + +function detectWordLimit(normalized: string): number | null { + for (const pattern of WORD_LIMIT_PATTERNS) { + const match = normalized.match(pattern); + if (!match) continue; + const limit = Number(match[1]); + if (Number.isInteger(limit) && limit >= WORD_LIMIT_MIN && limit <= WORD_LIMIT_MAX) { + return limit; + } + } + return null; +} + export function detectRequestedResponseShape(prompt: string): RequestedResponseShape { const normalized = prompt.replace(/\s+/g, " ").trim().toLowerCase(); if (TITLE_ONLY_PATTERNS.some((pattern) => pattern.test(normalized))) { @@ -872,7 +908,86 @@ export function detectRequestedResponseShape(prompt: string): RequestedResponseS /\b(?:exactly|in|as|using|give(?: me)?|return|output|provide|write)\s+(\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+(?:concise\s+|short\s+)?(?:bullet(?:\s+points?)?|bullets)\b/, ); const count = bulletMatch ? parseRequestedCount(bulletMatch[1]) : null; - return count ? { kind: "bullets", count } : { kind: "memo" }; + if (count) return { kind: "bullets", count }; + + if (SINGLE_SENTENCE_PATTERNS.some((pattern) => pattern.test(normalized))) { + return { kind: "sentence" }; + } + if (SINGLE_PARAGRAPH_PATTERNS.some((pattern) => pattern.test(normalized))) { + return { kind: "paragraph" }; + } + const wordLimit = detectWordLimit(normalized); + if (wordLimit !== null) return { kind: "word_limit", limit: wordLimit }; + + return { kind: "memo" }; +} + +/** + * System-prompt fragments for a requested response shape. One function so the + * shape detector, the model instruction, and the deterministic policy can + * never disagree about which shapes exist — adding a union member without a + * branch here is a compile error via the exhaustive switch. + */ +export function responseShapeSystemInstructions(shape: RequestedResponseShape): { + shapeInstruction: string; + citationInstruction: string; + calculationInstruction: string; +} { + const inlineCitations = + "Use [1], [2], [3] inline cite markers when a statement has a supported source URL."; + const compactCalculation = + "If the request requires a calculation or reconciliation, include the exact derivation and pass/fail conclusion within the requested response shape."; + switch (shape.kind) { + case "title_only": + return { + shapeInstruction: + "The user requested a title-only response. Output exactly one plain-text title line with no heading, label, bullets, explanation, or memo sections.", + citationInstruction: "Do not include citation markers in the title.", + calculationInstruction: "", + }; + case "bullets": + return { + shapeInstruction: `The user requested exactly ${shape.count} bullets. Output exactly ${shape.count} Markdown bullet lines beginning with "- " and no heading, preamble, conclusion, or memo sections.`, + citationInstruction: inlineCitations, + calculationInstruction: + "If the request requires a calculation or reconciliation, include the exact derivation and pass/fail conclusion within the requested bullet count.", + }; + case "sentence": + return { + shapeInstruction: + "The user requested a single-sentence response. Output exactly one sentence with no heading, bullets, preamble, or memo sections.", + citationInstruction: inlineCitations, + calculationInstruction: compactCalculation, + }; + case "paragraph": + return { + shapeInstruction: + "The user requested a single-paragraph response. Output exactly one plain-prose paragraph with no heading, bullets, or memo sections.", + citationInstruction: inlineCitations, + calculationInstruction: compactCalculation, + }; + case "word_limit": + return { + shapeInstruction: `The user requested a response of at most ${shape.limit} words. Stay within ${shape.limit} words of plain prose with no heading, bullets, or memo sections.`, + citationInstruction: inlineCitations, + calculationInstruction: compactCalculation, + }; + case "memo": + return { + shapeInstruction: `Produce a banker-style memo. Do not include To, From, Date, or Subject headers. Use exactly these markdown section headings: +1. Short answer (one sentence with citation markers like [1] [2]) +2. Why it matters (one paragraph with citation markers) +3. Evidence (3-5 bullets, each citing a source) +4. Risks / unknowns (2-3 bullets) +5. Next action (one imperative sentence)`, + citationInstruction: inlineCitations, + calculationInstruction: `If the user's request involves a numeric calculation, reconciliation, balancing check, or explicitly +asks you to "show your math" / "show your work" / confirm a total: state the actual derivation +(the specific numbers and operation, e.g. "$X - $Y = $Z") and an explicit pass/fail confirmation +of what they asked you to verify inside "Why it matters" - do not just assert the conclusion. This +does not apply to ordinary research/company/market questions.`, + }; + } } function sourceUrl(source: string): string | null { @@ -910,6 +1025,28 @@ function citationIndices(text: string): number[] { return [...text.matchAll(/\[(\d+)\]/g)].map((match) => Number(match[1])); } +// Split on sentence-final punctuation, but keep a citation marker that trails +// the terminator ("...claim. [1]") attached to the sentence it annotates. +// Shared by the superlative gate and the sentence/word-limit shape policies. +const SENTENCE_SPLITTER = /(?<=[.!?])\s+(?!\[\d+\])/; + +// Flatten memo prose into plain sentences: markdown bullet/number prefixes +// removed per line, whitespace collapsed. +function plainProse(parts: string[]): string { + return parts + .map((part) => + part + .split("\n") + .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim()) + .filter(Boolean) + .join(" "), + ) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + /** * Sentence-level honesty gate for "best/strongest source|claim" superlatives. * @@ -929,9 +1066,7 @@ function gateSuperlativeClaims( return { text, sanitizedUngrounded: false }; } let sanitizedUngrounded = false; - // Split on sentence-final punctuation, but keep a citation marker that trails - // the terminator ("...claim. [1]") attached to the sentence it annotates. - const sentences = text.split(/(?<=[.!?])\s+(?!\[\d+\])/); + const sentences = text.split(SENTENCE_SPLITTER); const gated = sentences.map((sentence) => { if (!UNSUPPORTED_SUPERLATIVE_TEST.test(sentence)) return sentence; const grounded = citationIndices(sentence).some((idx) => supportedIdx.has(idx)); @@ -1062,6 +1197,56 @@ export function applyDeterministicResponsePolicy( }; } + if (shape.kind === "sentence") { + const prose = plainProse([honest.shortAnswer]) || plainProse([honest.whyItMatters]) || "Evidence review."; + const sentence = (prose.split(SENTENCE_SPLITTER)[0] ?? prose).trim().slice(0, 400); + return { + shortAnswer: hasSupportedUrl ? sentence : `Source needed: ${sentence}`.slice(0, 400), + whyItMatters: "", + risks: [], + nextAction: "", + }; + } + + if (shape.kind === "paragraph") { + const body = plainProse([honest.shortAnswer, honest.whyItMatters]) || "Evidence review."; + const paragraph = hasSupportedUrl ? body : `${body} ${SOURCE_NEEDED_LIMITATION}`; + return { + shortAnswer: paragraph.slice(0, 1600), + whyItMatters: "", + risks: [], + nextAction: "", + }; + } + + if (shape.kind === "word_limit") { + // Honesty costs two words of the budget when no supported URL exists — + // "Source needed:" — rather than busting the user's explicit limit with + // the full limitation sentence. + const prefix = hasSupportedUrl ? "" : "Source needed: "; + const budget = Math.max(1, shape.limit - (prefix ? 2 : 0)); + const prose = plainProse([honest.shortAnswer, honest.whyItMatters]) || "Evidence review."; + const words: string[] = []; + for (const sentence of prose.split(SENTENCE_SPLITTER)) { + const tokens = sentence.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) continue; + if (words.length === 0 && tokens.length > budget) { + // Even the first sentence overruns: hard-trim at the word boundary so + // the explicit limit still wins over completeness. + words.push(...tokens.slice(0, budget)); + break; + } + if (words.length + tokens.length > budget) break; + words.push(...tokens); + } + return { + shortAnswer: `${prefix}${words.join(" ") || "Evidence review."}`, + whyItMatters: "", + risks: [], + nextAction: "", + }; + } + const mustIncludeUrl = primarySupportedUrl !== null && requiresUrlInEveryBullet(prompt); const candidates = responseUnits(honest, mustIncludeUrl) .filter((unit) => unit !== SOURCE_NEEDED_LIMITATION && isCleanCompactUnit(unit)); @@ -1624,28 +1809,11 @@ export const runStreamingChat = internalAction({ ? `\n\nA server-side VERIFIED_CALCULATION is available in the context packet's "verifiedCalculation" field: ${bankReconciliationFact.fact} This arithmetic has already been computed and checked for you. State it verbatim (the exact numbers and the tie/no-tie conclusion) inside "Why it matters" instead of recomputing or restating different numbers.` : ""; const requestedResponseShape = detectRequestedResponseShape(args.prompt); - const responseShapeInstruction = requestedResponseShape.kind === "title_only" - ? "The user requested a title-only response. Output exactly one plain-text title line with no heading, label, bullets, explanation, or memo sections." - : requestedResponseShape.kind === "bullets" - ? `The user requested exactly ${requestedResponseShape.count} bullets. Output exactly ${requestedResponseShape.count} Markdown bullet lines beginning with \"- \" and no heading, preamble, conclusion, or memo sections.` - : `Produce a banker-style memo. Do not include To, From, Date, or Subject headers. Use exactly these markdown section headings: -1. Short answer (one sentence with citation markers like [1] [2]) -2. Why it matters (one paragraph with citation markers) -3. Evidence (3-5 bullets, each citing a source) -4. Risks / unknowns (2-3 bullets) -5. Next action (one imperative sentence)`; - const citationInstruction = requestedResponseShape.kind === "title_only" - ? "Do not include citation markers in the title." - : "Use [1], [2], [3] inline cite markers when a statement has a supported source URL."; - const calculationInstruction = requestedResponseShape.kind === "title_only" - ? "" - : requestedResponseShape.kind === "bullets" - ? "If the request requires a calculation or reconciliation, include the exact derivation and pass/fail conclusion within the requested bullet count." - : `If the user's request involves a numeric calculation, reconciliation, balancing check, or explicitly -asks you to "show your math" / "show your work" / confirm a total: state the actual derivation -(the specific numbers and operation, e.g. "$X - $Y = $Z") and an explicit pass/fail confirmation -of what they asked you to verify inside "Why it matters" - do not just assert the conclusion. This -does not apply to ordinary research/company/market questions.`; + const { + shapeInstruction: responseShapeInstruction, + citationInstruction, + calculationInstruction, + } = responseShapeSystemInstructions(requestedResponseShape); const systemPrompt = `You are NodeBench's evidence-first analyst. ${responseShapeInstruction} ${citationInstruction} ${liveGrounding.useLiveGrounding ? "Keep claims grounded in the web sources you retrieve. Prefer recency." : "Use only the selected memory/context packet and cached source refs; do not imply a fresh web search was performed."} If you can't find grounded evidence, say so explicitly. diff --git a/src/features/redesign/components/UniversalComposer.test.tsx b/src/features/redesign/components/UniversalComposer.test.tsx index ee5eb3fff..476e8e2b4 100644 --- a/src/features/redesign/components/UniversalComposer.test.tsx +++ b/src/features/redesign/components/UniversalComposer.test.tsx @@ -59,9 +59,17 @@ describe("UniversalComposer runtime controls", () => { fireEvent.click(screen.getByRole("button", { name: "Run research" })); expect(onSubmit).toHaveBeenCalledTimes(1); - // The parent flips streaming after accepting the first click. The control at - // the same coordinates is now Stop, but it stays inert through dblclick #2. + // The parent flips streaming after accepting the first click. The submit + // button STAYS at the same coordinates — disabled, so the second click of + // a double-click is structurally a no-op at any double-click interval. + // Stop lives in its own reserved slot and is also arm-delayed. rerender(); + const submit = screen.getByRole("button", { name: "Run research" }); + expect(submit).toBeDisabled(); + fireEvent.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onStop).not.toHaveBeenCalled(); + const stop = screen.getByRole("button", { name: "Cancel active run" }); expect(stop).toBeDisabled(); fireEvent.click(stop); @@ -70,6 +78,11 @@ describe("UniversalComposer runtime controls", () => { act(() => vi.advanceTimersByTime(CANCEL_ARM_DELAY_MS)); expect(stop).toBeEnabled(); + // Even armed, the submit slot stays inert — the double-click landing zone + // never becomes a cancel control (issue #568's structural requirement). + fireEvent.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onStop).not.toHaveBeenCalled(); // Escape remains usable even when focus is no longer in the textarea. fireEvent.keyDown(document, { key: "Escape" }); diff --git a/src/features/redesign/components/UniversalComposer.tsx b/src/features/redesign/components/UniversalComposer.tsx index 1d822be93..db9e40ac8 100644 --- a/src/features/redesign/components/UniversalComposer.tsx +++ b/src/features/redesign/components/UniversalComposer.tsx @@ -718,23 +718,41 @@ export function UniversalComposer({ Chat now )} - {streaming && onStop ? ( + {/* Stop and submit are BOTH always rendered so neither ever moves: + the Stop slot is reserved (visibility:hidden) while idle, and the + submit button stays at identical coordinates while streaming — + merely disabled. The second click of a double-click therefore + lands on the inert submit, never on Stop, regardless of the + user's double-click interval. The CANCEL_ARM_DELAY_MS arming + below stays as defense in depth for pointer replays that do + land on Stop (e.g. Escape spam, automation). */} + {onStop ? ( - ) : ( - - )} + ) : null} + {onRunOnList && batchTargets.length > 0 && (