diff --git a/i18n/en.json b/i18n/en.json
index 2297129..9dc5771 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -42,6 +42,7 @@
"Response stopped": "Response stopped",
"Cosmo is responding": "Cosmo is responding",
"Response complete": "Response complete",
+ "Wait for Cosmo to finish responding before typing.": "Wait for Cosmo to finish responding before typing.",
"Your message": "Your message",
"Cosmo's reply": "Cosmo's reply",
"Regenerate response": "Regenerate response",
diff --git a/i18n/es.json b/i18n/es.json
index 99b9ce2..5e5861b 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -42,6 +42,7 @@
"Response stopped": "Respuesta detenida",
"Cosmo is responding": "Cosmo está respondiendo",
"Response complete": "Respuesta completa",
+ "Wait for Cosmo to finish responding before typing.": "Espera a que Cosmo termine de responder antes de escribir.",
"Your message": "Tu mensaje",
"Cosmo's reply": "Respuesta de Cosmo",
"Regenerate response": "Regenerar respuesta",
diff --git a/public/app.js b/public/app.js
index 28acdc1..709334e 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1146,11 +1146,40 @@ function renderMessages(liveMessages, status) {
});
}
- const streaming = status === 'streaming';
- promptInput.disabled = streaming;
+ syncComposerAvailability(status === 'streaming');
updateSendBtn();
}
+/**
+ * Keep the composer focusable while a reply streams (A3).
+ * Prefer readOnly over disabled so focus is not dropped to
, and
+ * explain the wait via aria-describedby. Restore focus on completion if
+ * it fell to the document body.
+ */
+function syncComposerAvailability(streaming) {
+ // Never leave the field disabled — that removes it from the tab order.
+ promptInput.disabled = false;
+ promptInput.readOnly = streaming;
+
+ const hint = document.getElementById('promptInputBusyHint');
+ if (hint) {
+ if (streaming) {
+ hint.textContent = t('Wait for Cosmo to finish responding before typing.');
+ promptInput.setAttribute('aria-describedby', hint.id);
+ } else {
+ hint.textContent = '';
+ promptInput.removeAttribute('aria-describedby');
+ }
+ }
+
+ if (!streaming) {
+ const activeEl = document.activeElement;
+ if (activeEl === document.body || activeEl === document.documentElement) {
+ promptInput.focus({ preventScroll: true });
+ }
+ }
+}
+
/**
* Split fingerprints so streaming token ticks can patch prose without
* recreating the article/heading chrome (keeps VoiceOver's place).
diff --git a/public/index.html b/public/index.html
index c22de6b..cffe3d7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -106,6 +106,8 @@ What's on your mind?
rows="1"
aria-label="Prompt input"
>
+
+
diff --git a/tests/dom/render.test.js b/tests/dom/render.test.js
index f72bf29..8b17b95 100644
--- a/tests/dom/render.test.js
+++ b/tests/dom/render.test.js
@@ -365,3 +365,80 @@ describe('accessibility characteristics of re-rendering', () => {
expect(q('.message__stopped')?.textContent).toBe('Response stopped');
});
});
+
+/**
+ * Accessibility acceptance — A3 (composer stays focusable mid-stream).
+ */
+describe('composer availability during streaming (A3)', () => {
+ it('uses readOnly instead of disabled while a response streams', async () => {
+ await bootApp({ messages: [] });
+ const input = q('#promptInput');
+ expect(input.disabled).toBe(false);
+ expect(input.readOnly).toBe(false);
+
+ fakeChats[0].setMessages(
+ [liveAssistant([{ type: 'text', text: 'partial' }], 'streaming')],
+ 'streaming',
+ );
+ await settle();
+
+ expect(input.disabled).toBe(false);
+ expect(input.readOnly).toBe(true);
+ expect(input.getAttribute('aria-describedby')).toBe('promptInputBusyHint');
+ expect(q('#promptInputBusyHint').textContent).toBe(
+ 'Wait for Cosmo to finish responding before typing.',
+ );
+ });
+
+ it('keeps focus on the composer across a stream tick', async () => {
+ await bootApp({ messages: [] });
+ const input = q('#promptInput');
+ input.focus();
+ expect(document.activeElement).toBe(input);
+
+ fakeChats[0].setMessages(
+ [liveAssistant([{ type: 'text', text: 'Hel' }], 'streaming')],
+ 'streaming',
+ );
+ await settle();
+ expect(document.activeElement).toBe(input);
+ expect(input.readOnly).toBe(true);
+
+ fakeChats[0].setMessages(
+ [liveAssistant([{ type: 'text', text: 'Hello' }], 'streaming')],
+ 'streaming',
+ );
+ await settle();
+ expect(document.activeElement).toBe(input);
+ });
+
+ it('clears readOnly and restores focus from body when the response completes', async () => {
+ await bootApp({ messages: [] });
+ const input = q('#promptInput');
+
+ fakeChats[0].setMessages(
+ [liveAssistant([{ type: 'text', text: 'partial' }], 'streaming')],
+ 'streaming',
+ );
+ await settle();
+ expect(input.readOnly).toBe(true);
+
+ // Simulate the pre-fix failure mode: focus already fallen to .
+ document.body.focus();
+ if (document.activeElement !== document.body) {
+ document.activeElement?.blur?.();
+ }
+ expect(['BODY', 'HTML']).toContain(document.activeElement?.tagName);
+
+ fakeChats[0].setMessages(
+ [liveAssistant([{ type: 'text', text: 'done' }], 'done')],
+ 'idle',
+ );
+ await settle();
+
+ expect(input.readOnly).toBe(false);
+ expect(input.disabled).toBe(false);
+ expect(input.hasAttribute('aria-describedby')).toBe(false);
+ expect(document.activeElement).toBe(input);
+ });
+});