Merge pull request #5811 from nesquena/release/stage-5721
Release: workspace switcher screen reader accessibility (#5721)
This commit is contained in:
7 files changed
+304
-14
No files matched your search
@@ -5,6 +5,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Workspace switcher is now navigable with a screen reader.** The workspace switcher exposes proper roles/labels and announces the "New Chat" workspace state transiently, and the closed workspace dropdown is kept out of screen-reader navigation so it isn't read while collapsed. Visual layout and click/keyboard behavior are unchanged. Thanks @rodboev. (#5721, #5671)
|
||||
|
||||
- **A background child stream no longer bumps its parent session to the top of the sidebar.** When a child (branched/background) stream was active, its activity re-sorted the parent session's sidebar time-bucket, yanking the parent around while you were working elsewhere. The sidebar sort no longer keys the parent's bucket off child-stream activity, so parents stay put. Thanks @rodboev. (#5729, #5699)
|
||||
|
||||
- **The embedded terminal now recovers from a dropped connection instead of silently freezing.** A transport-level failure on the terminal's output stream (session expired, gateway restarted, network drop) fired an EventSource `error` with no handler, so the terminal froze with no feedback. It now shows a "connection lost, reconnecting…" / "disconnected" line, lets the browser auto-reconnect a recoverable connection, and tears down a permanently-closed one so a restart can reconnect — notifying once per outage so a flapping connection can't flood the pane. Thanks @ai-ag2026. (#5786)
|
||||
|
||||
@@ -214,6 +214,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Configured',
|
||||
ws_search_placeholder: 'Search workspaces…',
|
||||
ws_no_results: 'No workspaces found',
|
||||
workspace_switcher_aria: 'Switch workspace. Current workspace: {0}.',
|
||||
new_session_workspace_announce: 'New chat started in workspace: {0}.',
|
||||
workspace_new_worktree_conversation: 'New conversation in worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Create an isolated git worktree for this workspace.',
|
||||
workspace_worktree_created: 'Worktree conversation created',
|
||||
@@ -1913,6 +1915,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Configurato',
|
||||
ws_search_placeholder: 'Cerca workspace…',
|
||||
ws_no_results: 'Nessun workspace trovato',
|
||||
workspace_switcher_aria: 'Cambia workspace. Workspace corrente: {0}.',
|
||||
new_session_workspace_announce: 'Nuova chat avviata nel workspace: {0}.',
|
||||
workspace_new_worktree_conversation: 'Nuova conversazione in worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Crea un worktree git isolato per questo workspace.',
|
||||
workspace_worktree_created: 'Conversazione worktree creata',
|
||||
@@ -3603,6 +3607,8 @@ const LOCALES = {
|
||||
model_group_configured: '設定済み',
|
||||
ws_search_placeholder: 'ワークスペースを検索…',
|
||||
ws_no_results: 'ワークスペースが見つかりません',
|
||||
workspace_switcher_aria: 'ワークスペースを切り替えます。現在のワークスペース: {0}。',
|
||||
new_session_workspace_announce: 'ワークスペース {0} で新しいチャットを開始しました。',
|
||||
workspace_new_worktree_conversation: 'worktree で新しい会話',
|
||||
workspace_new_worktree_conversation_meta: 'このワークスペース用に隔離された git worktree を作成します。',
|
||||
workspace_worktree_created: 'worktree 会話を作成しました',
|
||||
@@ -5387,6 +5393,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Настроенные',
|
||||
ws_search_placeholder: 'Поиск рабочих пространств…',
|
||||
ws_no_results: 'Рабочие пространства не найдены',
|
||||
workspace_switcher_aria: 'Переключить рабочее пространство. Текущее рабочее пространство: {0}.',
|
||||
new_session_workspace_announce: 'Новый чат запущен в рабочем пространстве: {0}.',
|
||||
workspace_new_worktree_conversation: 'Новый разговор в worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Создать изолированный git worktree для этого рабочего пространства.',
|
||||
workspace_worktree_created: 'Разговор в worktree создан',
|
||||
@@ -6904,6 +6912,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Configurados',
|
||||
ws_search_placeholder: 'Buscar espacios de trabajo…',
|
||||
ws_no_results: 'No se encontraron espacios de trabajo',
|
||||
workspace_switcher_aria: 'Cambiar espacio de trabajo. Espacio de trabajo actual: {0}.',
|
||||
new_session_workspace_announce: 'Nuevo chat iniciado en el espacio de trabajo: {0}.',
|
||||
workspace_new_worktree_conversation: 'Nueva conversación en worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Crear un git worktree aislado para este espacio de trabajo.',
|
||||
workspace_worktree_created: 'Conversación en worktree creada',
|
||||
@@ -9525,6 +9535,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Konfiguriert',
|
||||
ws_search_placeholder: 'Arbeitsbereiche suchen…',
|
||||
ws_no_results: 'Keine Arbeitsbereiche gefunden',
|
||||
workspace_switcher_aria: 'Workspace wechseln. Aktueller Workspace: {0}.',
|
||||
new_session_workspace_announce: 'Neuer Chat im Workspace gestartet: {0}.',
|
||||
workspace_new_worktree_conversation: 'Neue Unterhaltung in Worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Erstellt einen isolierten git worktree für diesen Arbeitsbereich.',
|
||||
workspace_worktree_created: 'Worktree-Unterhaltung erstellt',
|
||||
@@ -10160,6 +10172,8 @@ const LOCALES = {
|
||||
model_group_configured: '已配置',
|
||||
ws_search_placeholder: '搜索工作区…',
|
||||
ws_no_results: '未找到工作区',
|
||||
workspace_switcher_aria: '切换工作区。当前工作区:{0}。',
|
||||
new_session_workspace_announce: '已在工作区 {0} 中开始新聊天。',
|
||||
workspace_new_worktree_conversation: '在 worktree 中新建对话',
|
||||
workspace_new_worktree_conversation_meta: '为此工作区创建隔离的 git worktree。',
|
||||
workspace_worktree_created: '已创建 worktree 对话',
|
||||
@@ -11810,6 +11824,8 @@ const LOCALES = {
|
||||
model_group_configured: '已設定',
|
||||
ws_search_placeholder: '搜尋工作區…',
|
||||
ws_no_results: '找不到工作區',
|
||||
workspace_switcher_aria: '切換工作區。目前工作區:{0}。',
|
||||
new_session_workspace_announce: '已在工作區 {0} 中開始新聊天。',
|
||||
workspace_new_worktree_conversation: '在 worktree 中新增對話',
|
||||
workspace_new_worktree_conversation_meta: '為此工作區建立隔離的 git worktree。',
|
||||
workspace_worktree_created: '已建立 worktree 對話',
|
||||
@@ -13436,6 +13452,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Configurados',
|
||||
ws_search_placeholder: 'Buscar espaços de trabalho…',
|
||||
ws_no_results: 'Nenhum espaço de trabalho encontrado',
|
||||
workspace_switcher_aria: 'Alternar espaço de trabalho. Espaço de trabalho atual: {0}.',
|
||||
new_session_workspace_announce: 'Novo chat iniciado no espaço de trabalho: {0}.',
|
||||
workspace_new_worktree_conversation: 'Nova conversa em worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Cria um git worktree isolado para este espaço de trabalho.',
|
||||
workspace_worktree_created: 'Conversa em worktree criada',
|
||||
@@ -15002,6 +15020,8 @@ const LOCALES = {
|
||||
model_group_configured: '구성됨',
|
||||
ws_search_placeholder: '워크스페이스 검색…',
|
||||
ws_no_results: '워크스페이스를 찾을 수 없습니다',
|
||||
workspace_switcher_aria: '워크스페이스 전환. 현재 워크스페이스: {0}.',
|
||||
new_session_workspace_announce: '워크스페이스 {0}에서 새 채팅을 시작했습니다.',
|
||||
workspace_new_worktree_conversation: 'worktree에서 새 대화',
|
||||
workspace_new_worktree_conversation_meta: '이 워크스페이스용 격리된 git worktree를 만듭니다.',
|
||||
workspace_worktree_created: 'worktree 대화가 생성되었습니다',
|
||||
@@ -16685,6 +16705,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Configuré',
|
||||
ws_search_placeholder: 'Rechercher des espaces de travail\u2026',
|
||||
ws_no_results: 'Aucun espace de travail trouvé',
|
||||
workspace_switcher_aria: 'Changer d’espace de travail. Espace de travail actuel : {0}.',
|
||||
new_session_workspace_announce: 'Nouvelle discussion démarrée dans l’espace de travail : {0}.',
|
||||
workspace_new_worktree_conversation: 'Nouvelle conversation dans un worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Créer un worktree git isolé pour cet espace de travail.',
|
||||
workspace_worktree_created: 'Conversation Worktree créée',
|
||||
@@ -19787,6 +19809,8 @@ const LOCALES = {
|
||||
workspace_worktree_created: 'Worktree konverzace vytvořena',
|
||||
workspace_worktree_failed: 'Vytvoření worktree selhalo: ',
|
||||
ws_no_results: 'Nenalezeny žádné pracovní prostory',
|
||||
workspace_switcher_aria: 'Přepnout pracovní prostor. Aktuální pracovní prostor: {0}.',
|
||||
new_session_workspace_announce: 'Nový chat spuštěn v pracovním prostoru: {0}.',
|
||||
ws_search_placeholder: 'Hledat pracovní prostory…',
|
||||
you: 'Vy',
|
||||
// tool/worklog references
|
||||
@@ -20013,6 +20037,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Yapılandırılmış',
|
||||
ws_search_placeholder: 'Çalışma alanlarını arayın\u2026',
|
||||
ws_no_results: 'Çalışma alanı bulunamadı',
|
||||
workspace_switcher_aria: 'Çalışma alanını değiştir. Geçerli çalışma alanı: {0}.',
|
||||
new_session_workspace_announce: 'Yeni sohbet şu çalışma alanında başlatıldı: {0}.',
|
||||
workspace_new_worktree_conversation: 'Worktree\'de yeni konuşma',
|
||||
workspace_new_worktree_conversation_meta: 'Bu çalışma alanı için yalıtılmış bir git çalışma ağacı oluşturun.',
|
||||
workspace_worktree_created: 'Worktree görüşmesi oluşturuldu',
|
||||
@@ -21689,6 +21715,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Skonfigurowane',
|
||||
ws_search_placeholder: 'Szukaj obszarów roboczych…',
|
||||
ws_no_results: 'Nie znaleziono żadnych obszarów roboczych',
|
||||
workspace_switcher_aria: 'Przełącz obszar roboczy. Bieżący obszar roboczy: {0}.',
|
||||
new_session_workspace_announce: 'Nowy czat rozpoczęty w obszarze roboczym: {0}.',
|
||||
workspace_new_worktree_conversation: 'Nowa konwersacja w worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Utwórz izolowane git worktree dla tego obszaru roboczego.',
|
||||
workspace_worktree_created: 'Utworzono konwersację w worktree',
|
||||
@@ -23351,6 +23379,8 @@ const LOCALES = {
|
||||
model_group_configured: 'Đã cấu hình',
|
||||
ws_search_placeholder: 'Tìm workspace…',
|
||||
ws_no_results: 'Không tìm thấy workspace nào',
|
||||
workspace_switcher_aria: 'Chuyển workspace. Workspace hiện tại: {0}.',
|
||||
new_session_workspace_announce: 'Đã bắt đầu cuộc trò chuyện mới trong workspace: {0}.',
|
||||
workspace_new_worktree_conversation: 'Cuộc trò chuyện mới trong worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Tạo git worktree độc lập cho workspace này.',
|
||||
workspace_worktree_created: 'Đã tạo cuộc trò chuyện worktree',
|
||||
|
||||
+5
-3
@@ -449,6 +449,7 @@
|
||||
</div>
|
||||
<div class="messages-inner" id="msgInner"></div>
|
||||
<div id="liveRunStatus" hidden></div>
|
||||
<div id="a11yAnnouncer" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="liveCompressionCards" class="live-compression-cards"></div>
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
@@ -591,6 +592,7 @@
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<div id="composerWorkspaceContext" class="sr-only"></div>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
<input type="file" id="fileInput" class="file-input-visually-hidden" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx,.zip,.tar,.gz,.tgz,.bz2,.xz">
|
||||
@@ -636,7 +638,7 @@
|
||||
<button class="composer-workspace-files-btn" id="btnWorkspacePanelToggle" type="button" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false" aria-label="Toggle workspace files panel">
|
||||
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
</button>
|
||||
<button class="composer-workspace-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
|
||||
<button class="composer-workspace-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" aria-label="Switch workspace" aria-haspopup="true" aria-expanded="false" aria-controls="composerWsDropdown" disabled>
|
||||
<span class="composer-workspace-label" id="composerWorkspaceLabel"></span>
|
||||
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
@@ -722,7 +724,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-mobile-config-panel" id="composerMobileConfigPanel" aria-label="Workspace, model, quota, reasoning, and context settings">
|
||||
<button class="composer-mobile-config-action" id="composerMobileWorkspaceAction" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace">
|
||||
<button class="composer-mobile-config-action" id="composerMobileWorkspaceAction" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" aria-label="Switch workspace" aria-haspopup="true" aria-expanded="false" aria-controls="composerWsDropdown">
|
||||
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="composer-mobile-config-copy"><span class="composer-mobile-config-kicker" data-i18n="composer_mobile_workspace">Workspace</span><span class="composer-mobile-config-value" id="composerMobileWorkspaceLabel"></span></span>
|
||||
</button>
|
||||
@@ -749,7 +751,7 @@
|
||||
<button class="ctx-compress-btn composer-mobile-context-compress" id="composerMobileCtxCompressBtn" type="button" style="display:none"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
||||
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown" hidden aria-hidden="true" inert></div>
|
||||
<div class="composer-reasoning-dropdown" id="composerReasoningDropdown">
|
||||
<div class="reasoning-option" data-effort="none">None</div>
|
||||
<div class="reasoning-option" data-effort="minimal">Minimal</div>
|
||||
|
||||
+73
-11
@@ -5648,19 +5648,24 @@ function syncWorkspaceDisplays(){
|
||||
const mobileAction=$('composerMobileWorkspaceAction');
|
||||
const mobileLabel=$('composerMobileWorkspaceLabel');
|
||||
const composerDropdown=$('composerWsDropdown');
|
||||
if(!hasWorkspace && composerDropdown) composerDropdown.classList.remove('open');
|
||||
if(!hasWorkspace && composerDropdown) _setWorkspaceDropdownOpenState(composerDropdown,false);
|
||||
// Only show workspace label once boot has finished to prevent
|
||||
// flash of "No workspace" before the saved session finishes loading.
|
||||
if(composerLabel) composerLabel.textContent=S._bootReady?label:'';
|
||||
if(mobileLabel) mobileLabel.textContent=S._bootReady?label:'';
|
||||
const composerExpanded=!!(composerDropdown&&composerDropdown.classList.contains('open'));
|
||||
if(composerChip){
|
||||
composerChip.disabled=!hasWorkspace;
|
||||
composerChip.title=hasWorkspace?ws:t('no_workspace');
|
||||
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
|
||||
composerChip.setAttribute('aria-label',hasWorkspace?t('workspace_switcher_aria',label):t('no_workspace'));
|
||||
composerChip.setAttribute('aria-expanded',composerExpanded?'true':'false');
|
||||
composerChip.classList.toggle('active',composerExpanded);
|
||||
}
|
||||
if(mobileAction){
|
||||
mobileAction.title=hasWorkspace?ws:t('no_workspace');
|
||||
mobileAction.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
|
||||
mobileAction.setAttribute('aria-label',hasWorkspace?t('workspace_switcher_aria',label):t('no_workspace'));
|
||||
mobileAction.setAttribute('aria-expanded',composerExpanded?'true':'false');
|
||||
mobileAction.classList.toggle('active',composerExpanded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5675,6 +5680,41 @@ async function loadWorkspaceList(){
|
||||
}catch(e){ return {workspaces:[], last:''}; }
|
||||
}
|
||||
|
||||
function _setWorkspaceDropdownOpenState(dd,open){
|
||||
if(!dd)return;
|
||||
dd.classList.toggle('open',!!open);
|
||||
dd.hidden=!open;
|
||||
dd.setAttribute('aria-hidden',open?'false':'true');
|
||||
if(open){
|
||||
try{dd.inert=false;}catch(_){}
|
||||
dd.removeAttribute('inert');
|
||||
}else{
|
||||
try{dd.inert=true;}catch(_){}
|
||||
dd.setAttribute('inert','');
|
||||
}
|
||||
}
|
||||
|
||||
function _getComposerWorkspaceFocusTarget(){
|
||||
const panel=(typeof $==='function')?$('composerMobileConfigPanel'):null;
|
||||
const mobileAction=(typeof $==='function')?$('composerMobileWorkspaceAction'):null;
|
||||
if(panel&&panel.classList.contains('open')&&mobileAction&&!mobileAction.disabled) return mobileAction;
|
||||
return (typeof $==='function')?$('composerWorkspaceChip'):null;
|
||||
}
|
||||
|
||||
function _focusComposerWorkspaceTarget(target){
|
||||
if(target&&!target.disabled&&typeof target.focus==='function'){
|
||||
try{target.focus({preventScroll:true});}
|
||||
catch(_){target.focus();}
|
||||
}
|
||||
}
|
||||
|
||||
function _shouldRestoreComposerWorkspaceFocus(dd){
|
||||
if(typeof document==='undefined') return true;
|
||||
const active=document.activeElement;
|
||||
if(!active||active===document.body) return true;
|
||||
return !!(dd&&dd.contains(active));
|
||||
}
|
||||
|
||||
function _renderWorkspaceAction(label, meta, iconSvg, onClick){
|
||||
const opt=document.createElement('div');
|
||||
opt.className='ws-opt ws-opt-action';
|
||||
@@ -5828,7 +5868,7 @@ function toggleWsDropdown(){
|
||||
closeProfileDropdown(); // close profile dropdown if open
|
||||
loadWorkspaceList().then(data=>{
|
||||
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?.workspace||S._profileDefaultWorkspace||data.last||'');
|
||||
dd.classList.add('open');
|
||||
_setWorkspaceDropdownOpenState(dd,true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5848,10 +5888,16 @@ function toggleComposerWsDropdown(){
|
||||
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
|
||||
loadWorkspaceList().then(data=>{
|
||||
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?.workspace||S._profileDefaultWorkspace||data.last||'');
|
||||
dd.classList.add('open');
|
||||
_setWorkspaceDropdownOpenState(dd,true);
|
||||
_positionComposerWsDropdown();
|
||||
if(chip) chip.classList.add('active');
|
||||
if(mobileAction) mobileAction.classList.add('active');
|
||||
if(chip){
|
||||
chip.classList.add('active');
|
||||
chip.setAttribute('aria-expanded','true');
|
||||
}
|
||||
if(mobileAction){
|
||||
mobileAction.classList.add('active');
|
||||
mobileAction.setAttribute('aria-expanded','true');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5861,10 +5907,16 @@ function closeWsDropdown(){
|
||||
const composerDd=$('composerWsDropdown');
|
||||
const composerChip=$('composerWorkspaceChip');
|
||||
const mobileAction=$('composerMobileWorkspaceAction');
|
||||
if(dd)dd.classList.remove('open');
|
||||
if(composerDd)composerDd.classList.remove('open');
|
||||
if(composerChip)composerChip.classList.remove('active');
|
||||
if(mobileAction)mobileAction.classList.remove('active');
|
||||
if(dd)_setWorkspaceDropdownOpenState(dd,false);
|
||||
if(composerDd)_setWorkspaceDropdownOpenState(composerDd,false);
|
||||
if(composerChip){
|
||||
composerChip.classList.remove('active');
|
||||
composerChip.setAttribute('aria-expanded','false');
|
||||
}
|
||||
if(mobileAction){
|
||||
mobileAction.classList.remove('active');
|
||||
mobileAction.setAttribute('aria-expanded','false');
|
||||
}
|
||||
}
|
||||
document.addEventListener('click',e=>{
|
||||
if(
|
||||
@@ -6308,6 +6360,10 @@ async function switchToWorkspace(path,name){
|
||||
if(typeof cancelEditMode==='function')cancelEditMode();
|
||||
if(typeof clearPreview==='function')clearPreview();
|
||||
}
|
||||
const composerDd=(typeof $==='function')?$('composerWsDropdown'):null;
|
||||
const restoreComposerFocusTarget=(composerDd&&composerDd.classList.contains('open')&&typeof _getComposerWorkspaceFocusTarget==='function')
|
||||
? _getComposerWorkspaceFocusTarget()
|
||||
: null;
|
||||
try{
|
||||
closeWsDropdown();
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
||||
@@ -6319,6 +6375,12 @@ async function switchToWorkspace(path,name){
|
||||
S._profileSwitchWorkspace=null;
|
||||
S._pendingSessionToolsets=null;
|
||||
syncTopbar();
|
||||
if(
|
||||
restoreComposerFocusTarget&&
|
||||
typeof _shouldRestoreComposerWorkspaceFocus==='function'&&
|
||||
_shouldRestoreComposerWorkspaceFocus(composerDd)&&
|
||||
typeof _focusComposerWorkspaceTarget==='function'
|
||||
) _focusComposerWorkspaceTarget(restoreComposerFocusTarget);
|
||||
await loadDir('.');
|
||||
if (_currentPanel === 'memory') await loadMemory(true);
|
||||
showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path)));
|
||||
|
||||
@@ -1050,6 +1050,62 @@ function _clearEmptyComposerModelOverride(){
|
||||
_emptyComposerModelOverrideHost._emptyComposerModelOverride=null;
|
||||
}
|
||||
|
||||
let _newSessionWorkspaceAnnouncementClearTimer=null;
|
||||
|
||||
function _setNewSessionWorkspaceCue(message){
|
||||
const announcer=$('a11yAnnouncer');
|
||||
const composerCue=$('composerWorkspaceContext');
|
||||
const msg=$('msg');
|
||||
const cueId='composerWorkspaceContext';
|
||||
if(_newSessionWorkspaceAnnouncementClearTimer&&typeof clearTimeout==='function'){
|
||||
clearTimeout(_newSessionWorkspaceAnnouncementClearTimer);
|
||||
_newSessionWorkspaceAnnouncementClearTimer=null;
|
||||
}
|
||||
const removeComposerCue=()=>{
|
||||
if(composerCue&&composerCue.textContent===message) composerCue.textContent='';
|
||||
if(msg){
|
||||
const ids=(msg.getAttribute('aria-describedby')||'')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter(id=>id!==cueId);
|
||||
if(ids.length) msg.setAttribute('aria-describedby',ids.join(' '));
|
||||
else msg.removeAttribute('aria-describedby');
|
||||
}
|
||||
};
|
||||
const clear=()=>{
|
||||
if(announcer&&announcer.textContent===message) announcer.textContent='';
|
||||
removeComposerCue();
|
||||
_newSessionWorkspaceAnnouncementClearTimer=null;
|
||||
};
|
||||
const announce=()=>{
|
||||
if(announcer) announcer.textContent=message;
|
||||
if(composerCue&&msg){
|
||||
composerCue.textContent=message;
|
||||
const ids=(msg.getAttribute('aria-describedby')||'')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter(id=>id!==cueId);
|
||||
ids.push(cueId);
|
||||
msg.setAttribute('aria-describedby',ids.join(' '));
|
||||
}
|
||||
if(typeof setTimeout==='function'){
|
||||
_newSessionWorkspaceAnnouncementClearTimer=setTimeout(clear,5000);
|
||||
}
|
||||
};
|
||||
if(announcer) announcer.textContent='';
|
||||
removeComposerCue();
|
||||
if(typeof requestAnimationFrame==='function') requestAnimationFrame(announce);
|
||||
else announce();
|
||||
}
|
||||
|
||||
function _announceNewSessionWorkspace(session){
|
||||
if(!session||!session.workspace) return;
|
||||
const name=(typeof getWorkspaceFriendlyName==='function')
|
||||
? getWorkspaceFriendlyName(session.workspace)
|
||||
: String(session.workspace).split('/').filter(Boolean).pop()||session.workspace;
|
||||
_setNewSessionWorkspaceCue(t('new_session_workspace_announce',name));
|
||||
}
|
||||
|
||||
function _setNewSessionPending(pending){
|
||||
const ids=['btnNewChat','btnTitlebarNewChat'];
|
||||
for (let i=0;i<ids.length;i++){
|
||||
@@ -1231,6 +1287,7 @@ async function newSession(flash, options={}){
|
||||
}
|
||||
updateQueueBadge(S.session.session_id);
|
||||
syncTopbar();renderMessages();
|
||||
if(typeof _announceNewSessionWorkspace==='function') _announceNewSessionWorkspace(S.session);
|
||||
// Keep new-chat first paint instant. The workspace tree / git badge can
|
||||
// refresh right after paint unless this caller explicitly needs it loaded
|
||||
// before continuing (profile/default-workspace binding path).
|
||||
|
||||
@@ -1519,6 +1519,7 @@
|
||||
}
|
||||
}
|
||||
html{background:var(--sidebar);}
|
||||
.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;clip-path:inset(50%)!important;white-space:nowrap!important;border:0!important;}
|
||||
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;flex-direction:column;}
|
||||
.layout{display:flex;width:100%;flex:1 1 auto;min-height:0;}
|
||||
.app-titlebar{display:flex;align-items:center;justify-content:center;height:38px;flex-shrink:0;background:var(--sidebar);border-bottom:1px solid var(--border);padding:0 12px;padding-top:var(--app-titlebar-safe-top);padding-left:max(12px,env(safe-area-inset-left,0));padding-right:max(12px,env(safe-area-inset-right,0));box-sizing:content-box;font-size:12px;color:var(--muted);user-select:none;-webkit-app-region:drag;position:relative;z-index:20;}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Issue #5671: workspace switcher and New Chat workspace announcements."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _block(source: str, start_marker: str, end_marker: str) -> str:
|
||||
start = source.find(start_marker)
|
||||
assert start != -1, f"{start_marker!r} not found"
|
||||
end = source.find(end_marker, start)
|
||||
assert end != -1, f"{end_marker!r} not found after {start_marker!r}"
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def test_composer_workspace_switchers_expose_action_and_popup_state():
|
||||
assert 'id="composerWorkspaceChip"' in INDEX_HTML
|
||||
assert 'id="composerMobileWorkspaceAction"' in INDEX_HTML
|
||||
assert 'aria-label="Switch workspace"' in INDEX_HTML
|
||||
assert 'aria-haspopup="true"' in INDEX_HTML
|
||||
assert 'aria-expanded="false"' in INDEX_HTML
|
||||
assert 'aria-controls="composerWsDropdown"' in INDEX_HTML
|
||||
assert INDEX_HTML.count('aria-controls="composerWsDropdown"') == 2
|
||||
|
||||
sync = _block(PANELS_JS, "function syncWorkspaceDisplays", "async function loadWorkspaceList")
|
||||
assert "const composerExpanded=!!(composerDropdown&&composerDropdown.classList.contains('open'))" in sync
|
||||
assert "composerChip.setAttribute('aria-label',hasWorkspace?t('workspace_switcher_aria',label):t('no_workspace'))" in sync
|
||||
assert "composerChip.setAttribute('aria-expanded',composerExpanded?'true':'false')" in sync
|
||||
assert "composerChip.classList.toggle('active',composerExpanded)" in sync
|
||||
assert "mobileAction.setAttribute('aria-label',hasWorkspace?t('workspace_switcher_aria',label):t('no_workspace'))" in sync
|
||||
assert "mobileAction.setAttribute('aria-expanded',composerExpanded?'true':'false')" in sync
|
||||
assert "mobileAction.classList.toggle('active',composerExpanded)" in sync
|
||||
|
||||
|
||||
def test_composer_workspace_dropdown_keeps_aria_expanded_in_sync():
|
||||
toggle = _block(PANELS_JS, "function toggleComposerWsDropdown", "function closeWsDropdown")
|
||||
close = _block(PANELS_JS, "function closeWsDropdown", "document.addEventListener('click'")
|
||||
|
||||
assert "chip.setAttribute('aria-expanded','true')" in toggle
|
||||
assert "mobileAction.setAttribute('aria-expanded','true')" in toggle
|
||||
assert "composerChip.setAttribute('aria-expanded','false')" in close
|
||||
assert "mobileAction.setAttribute('aria-expanded','false')" in close
|
||||
|
||||
|
||||
def test_closed_composer_workspace_dropdown_is_removed_from_screen_reader_navigation():
|
||||
assert 'id="composerWsDropdown" hidden aria-hidden="true" inert' in INDEX_HTML
|
||||
|
||||
helper = _block(PANELS_JS, "function _setWorkspaceDropdownOpenState", "function _getComposerWorkspaceFocusTarget")
|
||||
assert "dd.hidden=!open" in helper
|
||||
assert "dd.setAttribute('aria-hidden',open?'false':'true')" in helper
|
||||
assert "dd.removeAttribute('inert')" in helper
|
||||
assert "dd.setAttribute('inert','')" in helper
|
||||
|
||||
toggle = _block(PANELS_JS, "function toggleComposerWsDropdown", "function closeWsDropdown")
|
||||
close = _block(PANELS_JS, "function closeWsDropdown", "document.addEventListener('click'")
|
||||
assert "_setWorkspaceDropdownOpenState(dd,true)" in toggle
|
||||
assert "_setWorkspaceDropdownOpenState(composerDd,false)" in close
|
||||
|
||||
|
||||
def test_composer_workspace_cue_is_transient_not_a_persistent_editor_description():
|
||||
assert 'id="msg"' in INDEX_HTML
|
||||
assert 'aria-describedby="composerWorkspaceContext"' not in INDEX_HTML
|
||||
assert 'id="composerWorkspaceContext"' in INDEX_HTML
|
||||
assert 'class="sr-only"' in INDEX_HTML
|
||||
|
||||
sync = _block(PANELS_JS, "function syncWorkspaceDisplays", "async function loadWorkspaceList")
|
||||
assert "composerWorkspaceContext" not in sync
|
||||
assert "workspace_context_aria" not in sync
|
||||
assert "workspace_context_none" not in sync
|
||||
|
||||
|
||||
def test_workspace_selection_from_composer_dropdown_restores_focus_to_opening_trigger_once():
|
||||
focus_helpers = _block(PANELS_JS, "function _getComposerWorkspaceFocusTarget", "function _renderWorkspaceAction")
|
||||
switch = _block(PANELS_JS, "async function switchToWorkspace", "// ── Profile panel")
|
||||
|
||||
assert "const mobileAction=(typeof $==='function')?$('composerMobileWorkspaceAction'):null" in focus_helpers
|
||||
assert "if(panel&&panel.classList.contains('open')&&mobileAction&&!mobileAction.disabled) return mobileAction" in focus_helpers
|
||||
assert "return (typeof $==='function')?$('composerWorkspaceChip'):null" in focus_helpers
|
||||
assert "function _shouldRestoreComposerWorkspaceFocus(dd)" in focus_helpers
|
||||
assert "return !!(dd&&dd.contains(active))" in focus_helpers
|
||||
|
||||
assert "const restoreComposerFocusTarget=(composerDd&&composerDd.classList.contains('open')&&typeof _getComposerWorkspaceFocusTarget==='function')" in switch
|
||||
assert "_shouldRestoreComposerWorkspaceFocus(composerDd)" in switch
|
||||
assert "_focusComposerWorkspaceTarget(restoreComposerFocusTarget)" in switch
|
||||
assert switch.count("_focusComposerWorkspaceTarget(restoreComposerFocusTarget)") == 1
|
||||
assert switch.index("syncTopbar();") < switch.index("_focusComposerWorkspaceTarget(restoreComposerFocusTarget)") < switch.index("await loadDir('.')")
|
||||
assert "workspace_switched_to" in switch
|
||||
assert switch.index("_focusComposerWorkspaceTarget(restoreComposerFocusTarget)") < switch.index("workspace_switched_to")
|
||||
|
||||
|
||||
def test_new_chat_has_screen_reader_only_workspace_announcer():
|
||||
assert 'id="a11yAnnouncer"' in INDEX_HTML
|
||||
assert 'class="sr-only"' in INDEX_HTML
|
||||
assert 'role="status"' in INDEX_HTML
|
||||
assert 'aria-live="polite"' in INDEX_HTML
|
||||
assert 'aria-atomic="true"' in INDEX_HTML
|
||||
assert 'id="a11yAnnouncer" hidden' not in INDEX_HTML
|
||||
assert 'id="a11yAnnouncer" aria-hidden="true"' not in INDEX_HTML
|
||||
|
||||
assert ".sr-only{" in STYLE_CSS
|
||||
sr_only = _block(STYLE_CSS, ".sr-only{", "body{")
|
||||
assert "display:none" not in sr_only
|
||||
assert "visibility:hidden" not in sr_only
|
||||
assert "clip-path:inset(50%)" in sr_only
|
||||
|
||||
|
||||
def test_new_session_announces_started_workspace_without_leaving_stale_browse_text():
|
||||
helper = _block(SESSIONS_JS, "function _setNewSessionWorkspaceCue", "function _setNewSessionPending")
|
||||
new_session = _block(SESSIONS_JS, "async function newSession", "/**\n * Self-heal")
|
||||
|
||||
assert "const announcer=$('a11yAnnouncer')" in helper
|
||||
assert "const composerCue=$('composerWorkspaceContext')" in helper
|
||||
assert "composerCue.textContent=message" in helper
|
||||
assert "msg.setAttribute('aria-describedby',ids.join(' '))" in helper
|
||||
assert "announcer.textContent=message" in helper
|
||||
assert "setTimeout(clear,5000)" in helper
|
||||
assert "announcer.textContent=''" in helper
|
||||
assert "composerCue.textContent=''" in helper
|
||||
assert "msg.removeAttribute('aria-describedby')" in helper
|
||||
assert "getWorkspaceFriendlyName(session.workspace)" in helper
|
||||
assert "t('new_session_workspace_announce',name)" in helper
|
||||
assert "typeof requestAnimationFrame==='function'" in helper
|
||||
assert "if(typeof _announceNewSessionWorkspace==='function') _announceNewSessionWorkspace(S.session);" in new_session
|
||||
assert new_session.index("S.session=data.session") < new_session.index("_announceNewSessionWorkspace(S.session);")
|
||||
|
||||
|
||||
def test_workspace_a11y_i18n_keys_exist_in_english_locale():
|
||||
assert "workspace_switcher_aria: 'Switch workspace. Current workspace: {0}.'" in I18N_JS
|
||||
assert "workspace_context_aria" not in I18N_JS
|
||||
assert "workspace_context_none" not in I18N_JS
|
||||
assert "new_session_workspace_announce: 'New chat started in workspace: {0}.'" in I18N_JS
|
||||
Reference in new issue
Block a user