Skip to content

Commit 9447128

Browse files
committed
fix(webapp): step the composer's live region instead of reading every keystroke
Between the warning point and the limit the region announced a new count per character; it now steps in 200s and names the limit on reaching it.
1 parent edc7ea3 commit 9447128

2 files changed

Lines changed: 57 additions & 6 deletions

File tree

apps/webapp/app/components/dashboard-agent/message-limits.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
MAX_MESSAGE_BODY_BYTES,
88
MAX_MESSAGE_CHARS,
99
MAX_MESSAGE_PARTS,
10+
MESSAGE_ANNOUNCE_STEP,
1011
MESSAGE_CHARS_WARN_AT,
12+
MESSAGE_LIMIT_REACHED_ANNOUNCEMENT,
1113
messageCountAnnouncement,
1214
} from "./message-limits";
1315

@@ -71,13 +73,47 @@ describe("messageCountAnnouncement", () => {
7173
expect(messageCountAnnouncement(MESSAGE_CHARS_WARN_AT - 1)).toBe("");
7274
});
7375

74-
it("reads the count out from the warning point on", () => {
76+
it("speaks up on reaching the warning point, and again on the limit", () => {
7577
expect(messageCountAnnouncement(MESSAGE_CHARS_WARN_AT)).toBe(
76-
`${MESSAGE_CHARS_WARN_AT} / ${MAX_MESSAGE_CHARS}`
77-
);
78-
expect(messageCountAnnouncement(MAX_MESSAGE_CHARS)).toBe(
79-
`${MAX_MESSAGE_CHARS} / ${MAX_MESSAGE_CHARS}`
78+
`${MAX_MESSAGE_CHARS - MESSAGE_CHARS_WARN_AT} characters left`
8079
);
80+
expect(messageCountAnnouncement(MAX_MESSAGE_CHARS)).toBe(MESSAGE_LIMIT_REACHED_ANNOUNCEMENT);
81+
});
82+
83+
it("changes rarely enough to be worth listening to", () => {
84+
const spoken = new Set<string>();
85+
let changes = 0;
86+
let previous = messageCountAnnouncement(MESSAGE_CHARS_WARN_AT - 1);
87+
88+
for (let length = MESSAGE_CHARS_WARN_AT - 1; length <= MAX_MESSAGE_CHARS; length++) {
89+
const announcement = messageCountAnnouncement(length);
90+
if (announcement !== previous) changes++;
91+
previous = announcement;
92+
if (announcement) spoken.add(announcement);
93+
}
94+
95+
// One per step across the warning band, plus the limit itself.
96+
const expected = (MAX_MESSAGE_CHARS - MESSAGE_CHARS_WARN_AT) / MESSAGE_ANNOUNCE_STEP + 1;
97+
expect(spoken.size).toBe(expected);
98+
expect(changes).toBe(expected);
99+
expect(spoken).toContain(MESSAGE_LIMIT_REACHED_ANNOUNCEMENT);
100+
});
101+
102+
it("steps down through the band in order", () => {
103+
// Typing one character can only ever move the announcement forward.
104+
const seen: string[] = [];
105+
for (let length = MESSAGE_CHARS_WARN_AT; length <= MAX_MESSAGE_CHARS; length++) {
106+
const announcement = messageCountAnnouncement(length);
107+
if (seen.at(-1) !== announcement) seen.push(announcement);
108+
}
109+
110+
expect(seen).toEqual([
111+
"800 characters left",
112+
"600 characters left",
113+
"400 characters left",
114+
"200 characters left",
115+
MESSAGE_LIMIT_REACHED_ANNOUNCEMENT,
116+
]);
81117
});
82118
});
83119

apps/webapp/app/components/dashboard-agent/message-limits.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,28 @@ export const MAX_MESSAGE_CHARS = 8_000;
1010
/** The counter only shows near the limit, so a normal message never sees it. */
1111
export const MESSAGE_CHARS_WARN_AT = Math.floor(MAX_MESSAGE_CHARS * 0.9);
1212

13+
/** The live region rounds the remaining characters up to this, so it speaks in steps. */
14+
export const MESSAGE_ANNOUNCE_STEP = 200;
15+
16+
export const MESSAGE_LIMIT_REACHED_ANNOUNCEMENT = "Message limit reached";
17+
1318
/**
1419
* What the composer's live region says at this length: empty until the counter is worth
1520
* showing. The region itself stays mounted whatever this returns — several screen readers only
1621
* announce changes to a region that was already in the DOM.
22+
*
23+
* A live count would be read out once per keystroke, so this steps instead: four announcements
24+
* between the warning point and the limit, and one more on reaching it. The exact count stays in
25+
* the visible counter.
1726
*/
1827
export function messageCountAnnouncement(length: number): string {
19-
return length >= MESSAGE_CHARS_WARN_AT ? `${length} / ${MAX_MESSAGE_CHARS}` : "";
28+
if (length < MESSAGE_CHARS_WARN_AT) return "";
29+
30+
const remaining = Math.max(MAX_MESSAGE_CHARS - length, 0);
31+
if (remaining === 0) return MESSAGE_LIMIT_REACHED_ANNOUNCEMENT;
32+
33+
const step = Math.ceil(remaining / MESSAGE_ANNOUNCE_STEP) * MESSAGE_ANNOUNCE_STEP;
34+
return `${step} characters left`;
2035
}
2136

2237
/** A composed message is a handful of parts; dozens means something is wrong. */

0 commit comments

Comments
 (0)