Skip to content

Commit aced730

Browse files
committed
chore: merge feat/agent-message-quota-tri-12863 (composer escape follow-up)
2 parents b9656c0 + 36920a9 commit aced730

3 files changed

Lines changed: 66 additions & 14 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
22
import { useEffect, useRef } from "react";
33
import { Button } from "~/components/primitives/Buttons";
44
import { cn } from "~/utils/cn";
5-
import { composerKeepsEscape } from "./composer-escape";
5+
import { composerEscapeAction } from "./composer-escape";
66
import {
77
MAX_MESSAGE_CHARS,
88
MESSAGE_CHARS_WARN_AT,
@@ -38,6 +38,8 @@ export function DashboardAgentComposer({
3838
placeholderSuggestion?: string;
3939
}) {
4040
const ref = useRef<HTMLTextAreaElement>(null);
41+
// Armed = the next Escape is taken by the draft guard; anything else re-arms it.
42+
const escapeGuardArmed = useRef(true);
4143

4244
useEffect(() => {
4345
const el = ref.current;
@@ -90,15 +92,35 @@ export function DashboardAgentComposer({
9092
value={value}
9193
// Clamped as well as `maxLength`, so a programmatic paste can't exceed the cap.
9294
maxLength={MAX_MESSAGE_CHARS}
93-
onChange={(e) => onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS))}
95+
onChange={(e) => {
96+
escapeGuardArmed.current = true;
97+
onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS));
98+
}}
99+
onBlur={() => {
100+
escapeGuardArmed.current = true;
101+
}}
94102
onKeyDown={(e) => {
103+
if (e.key !== "Escape") {
104+
escapeGuardArmed.current = true;
105+
}
95106
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
96107
e.preventDefault();
97108
onSubmit();
98109
}
99-
// Keeping Escape from the panel's close handler, which skips a prevented event.
100-
if (e.key === "Escape" && composerKeepsEscape(value)) {
110+
// An Escape that cancels an IME composition is the user's, not the panel's: keep it
111+
// from the close handler without spending the draft guard's one step.
112+
if (e.key === "Escape" && e.nativeEvent.isComposing) {
113+
e.preventDefault();
114+
return;
115+
}
116+
// The first Escape on a draft is kept from the panel's close handler, which skips a
117+
// prevented event; a second one passes through unprevented and closes the panel.
118+
if (
119+
e.key === "Escape" &&
120+
composerEscapeAction(value, escapeGuardArmed.current) === "swallow"
121+
) {
101122
e.preventDefault();
123+
escapeGuardArmed.current = false;
102124
}
103125
// Only while empty, so with text present Tab keeps its normal focus behavior.
104126
if (e.key === "Tab" && !e.shiftKey && placeholderSuggestion && value === "") {
Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,43 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
13
import { describe, expect, it } from "vitest";
2-
import { composerKeepsEscape } from "./composer-escape";
4+
import { composerEscapeAction } from "./composer-escape";
5+
6+
const COMPOSER = readFileSync(join(__dirname, "DashboardAgentComposer.tsx"), "utf8");
37

48
describe("Escape while the composer has focus", () => {
5-
it("is kept by the composer while there is a draft, so the panel stays open", () => {
6-
expect(composerKeepsEscape("half a question about a failing run")).toBe(true);
9+
it("is kept by the composer on the first Escape with a draft, so the panel stays open", () => {
10+
expect(composerEscapeAction("half a question about a failing run", true)).toBe("swallow");
11+
});
12+
13+
it("lets a second consecutive Escape through, so the panel closes", () => {
14+
expect(composerEscapeAction("half a question about a failing run", false)).toBe("pass");
15+
});
16+
17+
it("guards the draft again once the guard is re-armed by typing", () => {
18+
expect(composerEscapeAction("half a question, now longer", true)).toBe("swallow");
719
});
820

9-
it("closes the panel when there is nothing to lose", () => {
10-
expect(composerKeepsEscape("")).toBe(false);
21+
it("closes the panel on the first Escape when there is nothing to lose", () => {
22+
expect(composerEscapeAction("", true)).toBe("pass");
1123
});
1224

1325
it("reads whitespace as nothing to lose, matching what Send accepts", () => {
14-
expect(composerKeepsEscape(" \n ")).toBe(false);
26+
expect(composerEscapeAction(" \n ", true)).toBe("pass");
27+
});
28+
});
29+
30+
// Source-level checks: the guard's step is spent in the composer, not in the pure helper.
31+
describe("the composer's Escape wiring", () => {
32+
it("disarms the guard in the swallow branch, so the next Escape passes", () => {
33+
expect(COMPOSER).toMatch(
34+
/=== "swallow"\s*\)\s*\{\s*e\.preventDefault\(\);\s*escapeGuardArmed\.current = false;/
35+
);
36+
});
37+
38+
it("swallows an IME-cancelling Escape without spending the guard's step", () => {
39+
expect(COMPOSER).toMatch(
40+
/e\.key === "Escape" && e\.nativeEvent\.isComposing\s*\)\s*\{\s*e\.preventDefault\(\);\s*return;/
41+
);
1542
});
1643
});
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
export type ComposerEscapeAction = "swallow" | "pass";
2+
13
/**
24
* The panel closes on Escape unless a child has already prevented the event's default —
3-
* `defaultPrevented` is how a child vetoes the close. A composer holding a draft takes the
4-
* first Escape for itself, so the draft survives; an empty one lets Escape close the panel.
5+
* `defaultPrevented` is how a child vetoes the close. Escape is two-step while a draft
6+
* exists: the first one is swallowed so the draft survives, a second consecutive one
7+
* passes through and closes the panel. Anything else re-arms the guard.
58
*/
6-
export function composerKeepsEscape(value: string): boolean {
7-
return value.trim() !== "";
9+
export function composerEscapeAction(draft: string, guardArmed: boolean): ComposerEscapeAction {
10+
return draft.trim() !== "" && guardArmed ? "swallow" : "pass";
811
}

0 commit comments

Comments
 (0)