Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 31 additions & 2 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>, 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).
Expand Down
2 changes: 2 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ <h1 class="heading-small empty-state__heading">What's on your mind?</h1>
rows="1"
aria-label="Prompt input"
></textarea>
<!-- Explains transient readOnly while streaming (A3); wired via aria-describedby. -->
<p id="promptInputBusyHint" class="visually-hidden"></p>
</div>

<div class="composer__toolbar composer_toolarea" role="toolbar" aria-label="Composer actions">
Expand Down
77 changes: 77 additions & 0 deletions tests/dom/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>.
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);
});
});
Loading