fix: keep work-status panel reachable when all sections are hidden (#2805)
* fix: keep work-status panel reachable when all sections are hidden When every section was unchecked in the Panel Sections dialog, the panel went transparent and inert, making the settings gear icon unreachable. The only recovery was knowing to toggle the panel off and on from the header, which still rendered the same empty/inert state. Changes: - Panel stays interactive (not inert) when visible, even with zero rendered sections. This matches how other panels in the app use inert only for visually-collapsed (width/height = 0) states. - Empty state shows 'No sections selected' with a link to reopen the sections dialog, matching the centered text-muted-foreground pattern used by the file tree, review panel, and home page empty states. - Sections dialog gains a 'Show all' link (visible whenever any section is hidden) and a warning when all sections are unchecked, matching the keybinds settings 'Reset to defaults' pattern. - Added i18n keys to all 10 locale files (English fallback). Fixes #2804 * Round 1: fix interactive guard for fresh-mount; translate i18n keys Address openchamber-bot review findings: 1. (blocker) Replace English fallback strings in all 10 non-English locale files with real translations per locale-ui-patterns guidance. 2. (non-blocker) Restore the renderedSections > 0 guard for the transient no-data-on-mount state so the panel doesn't flash a bare bordered card. The interactive condition is now: visible && (renderedSections > 0 || allSectionsHidden) Empty-state rendering is gated on allSectionsHidden alone (not renderedSections === 0) so it works correctly on fresh mount when all sections were already hidden in persisted settings. Validation: tsc --noEmit: 0 errors bun test work-status: 34 pass, 0 fail * Round 2: use .every() guard, dedup chooseLabel key, add tests 1. Replace >= length check with areAllWorkStatusSectionsHidden() helper that uses .every() — stale section ids left in persisted settings from a future removal can no longer inflate the count. 2. Remove duplicate chooseLabel i18n key from all 11 locales — the empty-state link now reuses the existing sections.open key. 3. Add 6 focused tests for areAllWorkStatusSectionsHidden covering empty, null/undefined, partial, full, stale-id, and stale+full. Validation: tsc --noEmit: 0 errors bun test work-status: 40 pass (6 new), 0 fail
This commit is contained in:
@@ -13,7 +13,7 @@ import { WorkStatusMcpSection } from './WorkStatusMcpSection';
|
||||
import { WorkStatusPinnedSection } from './WorkStatusPinnedSection';
|
||||
import { WorkStatusContextSection } from './WorkStatusContextSection';
|
||||
import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog';
|
||||
import { isWorkStatusSectionVisible } from './sections';
|
||||
import { areAllWorkStatusSectionsHidden, isWorkStatusSectionVisible } from './sections';
|
||||
import { WorkStatusPresenceProvider } from './presence';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
@@ -82,9 +82,14 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
|
||||
// out with something in it rather than emptying first, and its subscriptions
|
||||
// stop once it is truly gone.
|
||||
const [contentMounted, setContentMounted] = React.useState(visible);
|
||||
// Hidden, mid-collapse, or reporting nothing: in each case the card is not
|
||||
// something the user can act on, so it should not be reachable.
|
||||
const interactive = visible && renderedSections > 0;
|
||||
// Hidden or mid-collapse: the card is not something the user can act on.
|
||||
// When `visible` but all sections are hidden, the panel stays interactive so
|
||||
// the settings button remains reachable — otherwise there is no way to
|
||||
// re-enable sections. The previous `renderedSections > 0` guard is preserved
|
||||
// for the transient "no data yet" state so the panel doesn't flash a bare
|
||||
// bordered card on first mount.
|
||||
const allSectionsHidden = areAllWorkStatusSectionsHidden(hiddenSections);
|
||||
const interactive = visible && (renderedSections > 0 || allSectionsHidden);
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setContentMounted(true);
|
||||
@@ -180,9 +185,9 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
|
||||
// separates the two without going fully opaque.
|
||||
'oc-glass-panel',
|
||||
],
|
||||
// An empty card is a border around a settings icon, which reads as a
|
||||
// fault rather than as "nothing to report".
|
||||
renderedSections === 0 && 'border-transparent bg-transparent shadow-none',
|
||||
// When every section is hidden the card keeps its border and background
|
||||
// so the settings button stays discoverable — going transparent made the
|
||||
// only recovery path unreachable.
|
||||
'motion-reduce:transition-none',
|
||||
'rounded-xl border border-[var(--interactive-border)]',
|
||||
!overlay && 'bg-[var(--surface-muted)]/40',
|
||||
@@ -246,6 +251,19 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
|
||||
</WorkStatusPresenceProvider>
|
||||
) : null}
|
||||
|
||||
{contentMounted && allSectionsHidden ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-8 text-center">
|
||||
<span className="text-sm text-muted-foreground">{t('chat.workStatus.sections.allHidden')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSectionsDialogOpen(true)}
|
||||
className="mt-2 text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground"
|
||||
>
|
||||
{t('chat.workStatus.sections.open')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WorkStatusSectionsDialog open={sectionsDialogOpen} onOpenChange={setSectionsDialogOpen} />
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
WORK_STATUS_SECTION_IDS,
|
||||
WORK_STATUS_SECTION_LABEL_KEYS,
|
||||
areAllWorkStatusSectionsHidden,
|
||||
isWorkStatusSectionVisible,
|
||||
} from './sections';
|
||||
|
||||
@@ -29,6 +30,12 @@ export const WorkStatusSectionsDialog: React.FC<{
|
||||
const { t } = useI18n();
|
||||
const hidden = useUIStore((state) => state.workStatusHiddenSections);
|
||||
const setSectionVisible = useUIStore((state) => state.setWorkStatusSectionVisible);
|
||||
const setHiddenSections = useUIStore((state) => state.setWorkStatusHiddenSections);
|
||||
|
||||
const allVisible = hidden.length === 0;
|
||||
const noneVisible = areAllWorkStatusSectionsHidden(hidden);
|
||||
|
||||
const handleShowAll = () => setHiddenSections([]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -50,6 +57,21 @@ export const WorkStatusSectionsDialog: React.FC<{
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!allVisible ? (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
{noneVisible ? (
|
||||
<span className="text-xs text-destructive">{t('chat.workStatus.sections.noneWarning')}</span>
|
||||
) : <span />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowAll}
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground"
|
||||
>
|
||||
{t('chat.workStatus.sections.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
WORK_STATUS_SECTION_IDS,
|
||||
WORK_STATUS_SECTION_LABEL_KEYS,
|
||||
areAllWorkStatusSectionsHidden,
|
||||
isWorkStatusSectionVisible,
|
||||
sanitizeWorkStatusHiddenSections,
|
||||
} from './sections';
|
||||
@@ -30,6 +31,37 @@ describe('isWorkStatusSectionVisible', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('areAllWorkStatusSectionsHidden', () => {
|
||||
test('returns false when no sections are hidden', () => {
|
||||
expect(areAllWorkStatusSectionsHidden([])).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for null and undefined', () => {
|
||||
expect(areAllWorkStatusSectionsHidden(null)).toBe(false);
|
||||
expect(areAllWorkStatusSectionsHidden(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when only some sections are hidden', () => {
|
||||
expect(areAllWorkStatusSectionsHidden(['usage', 'tasks'])).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when every known section is hidden', () => {
|
||||
expect(areAllWorkStatusSectionsHidden([...WORK_STATUS_SECTION_IDS])).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores stale ids that are no longer in the section list', () => {
|
||||
// A future section-ID removal should not trick the length check into
|
||||
// reporting all-hidden when real sections are still visible.
|
||||
const withStale = [...WORK_STATUS_SECTION_IDS.slice(0, -1), 'removed_section'];
|
||||
expect(areAllWorkStatusSectionsHidden(withStale)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true even with extra stale ids alongside all real ones', () => {
|
||||
const withExtra = [...WORK_STATUS_SECTION_IDS, 'removed_section'];
|
||||
expect(areAllWorkStatusSectionsHidden(withExtra)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeWorkStatusHiddenSections', () => {
|
||||
test('keeps known ids and drops everything else', () => {
|
||||
expect(sanitizeWorkStatusHiddenSections(['usage', 'nope', 42, null, 'tasks']))
|
||||
|
||||
@@ -49,6 +49,17 @@ export const isWorkStatusSectionVisible = (
|
||||
id: WorkStatusSectionId,
|
||||
): boolean => !hidden?.includes(id);
|
||||
|
||||
/**
|
||||
* True when every known section id appears in the hidden set.
|
||||
*
|
||||
* Uses `.every()` instead of a length comparison so that stale ids left over
|
||||
* from a removed section cannot inflate the count past the current list length.
|
||||
*/
|
||||
export const areAllWorkStatusSectionsHidden = (
|
||||
hidden: readonly string[] | null | undefined,
|
||||
): boolean =>
|
||||
hidden != null && WORK_STATUS_SECTION_IDS.every((id) => hidden.includes(id));
|
||||
|
||||
export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<WorkStatusSectionId>();
|
||||
|
||||
@@ -3001,6 +3001,9 @@ export const dict = {
|
||||
'chat.workStatus.sections.open': 'Abschnitte wählen',
|
||||
'chat.workStatus.sections.dialogTitle': 'Panel-Abschnitte',
|
||||
'chat.workStatus.sections.dialogDescription': 'Wähle, was das Arbeitsstatus-Panel zeigt. Ausgeblendete Abschnitte behalten ihre Daten und werden nur nicht angezeigt.',
|
||||
'chat.workStatus.sections.allHidden': 'Keine Abschnitte ausgewählt',
|
||||
'chat.workStatus.sections.showAll': 'Alle anzeigen',
|
||||
'chat.workStatus.sections.noneWarning': 'Das Panel wird leer angezeigt.',
|
||||
'header.workStatusPanel.toggleAria': 'Arbeitsstatus-Panel umschalten',
|
||||
'header.workStatusPanel.hide': 'Arbeitsstatus ausblenden',
|
||||
'header.workStatusPanel.show': 'Arbeitsstatus anzeigen',
|
||||
|
||||
@@ -3003,6 +3003,9 @@ export const dict = {
|
||||
'chat.workStatus.sections.open': 'Choose sections',
|
||||
'chat.workStatus.sections.dialogTitle': 'Panel sections',
|
||||
'chat.workStatus.sections.dialogDescription': 'Choose what the work-status panel shows. Hidden sections keep their data — they are only left out of the panel.',
|
||||
'chat.workStatus.sections.allHidden': 'No sections selected',
|
||||
'chat.workStatus.sections.showAll': 'Show all',
|
||||
'chat.workStatus.sections.noneWarning': 'The panel will appear empty.',
|
||||
'header.workStatusPanel.toggleAria': 'Toggle work-status panel',
|
||||
'header.workStatusPanel.hide': 'Hide work status',
|
||||
'header.workStatusPanel.show': 'Show work status',
|
||||
|
||||
@@ -3004,6 +3004,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': 'Elegir secciones',
|
||||
'chat.workStatus.sections.dialogTitle': 'Secciones del panel',
|
||||
'chat.workStatus.sections.dialogDescription': 'Elige qué muestra el panel de estado. Las secciones ocultas conservan sus datos: solo no aparecen en el panel.',
|
||||
'chat.workStatus.sections.allHidden': 'Ninguna sección seleccionada',
|
||||
'chat.workStatus.sections.showAll': 'Mostrar todas',
|
||||
'chat.workStatus.sections.noneWarning': 'El panel aparecerá vacío.',
|
||||
'header.workStatusPanel.toggleAria': 'Alternar panel de estado',
|
||||
'header.workStatusPanel.hide': 'Ocultar estado del trabajo',
|
||||
'header.workStatusPanel.show': 'Mostrar estado del trabajo',
|
||||
|
||||
@@ -3001,6 +3001,9 @@ export const dict = {
|
||||
'chat.workStatus.sections.open': 'Choisir les sections',
|
||||
'chat.workStatus.sections.dialogTitle': 'Sections du panneau',
|
||||
'chat.workStatus.sections.dialogDescription': 'Choisis ce qu\'affiche le panneau d\'état. Les sections masquées conservent leurs données, elles sont seulement absentes du panneau.',
|
||||
'chat.workStatus.sections.allHidden': 'Aucune section sélectionnée',
|
||||
'chat.workStatus.sections.showAll': 'Tout afficher',
|
||||
'chat.workStatus.sections.noneWarning': 'Le panneau apparaîtra vide.',
|
||||
'header.workStatusPanel.toggleAria': 'Basculer le panneau d\'état',
|
||||
'header.workStatusPanel.hide': 'Masquer l\'état du travail',
|
||||
'header.workStatusPanel.show': 'Afficher l\'état du travail',
|
||||
|
||||
@@ -3003,6 +3003,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': 'セクションを選択',
|
||||
'chat.workStatus.sections.dialogTitle': 'パネルのセクション',
|
||||
'chat.workStatus.sections.dialogDescription': '作業状況パネルに表示する内容を選びます。非表示のセクションもデータは保持され、表示されないだけです。',
|
||||
'chat.workStatus.sections.allHidden': 'セクションが選択されていません',
|
||||
'chat.workStatus.sections.showAll': 'すべて表示',
|
||||
'chat.workStatus.sections.noneWarning': 'パネルが空で表示されます。',
|
||||
'header.workStatusPanel.toggleAria': '作業状況パネルを切り替え',
|
||||
'header.workStatusPanel.hide': '作業状況を非表示',
|
||||
'header.workStatusPanel.show': '作業状況を表示',
|
||||
|
||||
@@ -3003,6 +3003,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': '섹션 선택',
|
||||
'chat.workStatus.sections.dialogTitle': '패널 섹션',
|
||||
'chat.workStatus.sections.dialogDescription': '작업 상태 패널에 표시할 항목을 선택하세요. 숨긴 섹션도 데이터는 유지되며 패널에만 나타나지 않습니다.',
|
||||
'chat.workStatus.sections.allHidden': '선택된 섹션 없음',
|
||||
'chat.workStatus.sections.showAll': '모두 표시',
|
||||
'chat.workStatus.sections.noneWarning': '패널이 비어 보입니다.',
|
||||
'header.workStatusPanel.toggleAria': '작업 상태 패널 전환',
|
||||
'header.workStatusPanel.hide': '작업 상태 숨기기',
|
||||
'header.workStatusPanel.show': '작업 상태 표시',
|
||||
|
||||
@@ -3020,6 +3020,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': 'Wybierz sekcje',
|
||||
'chat.workStatus.sections.dialogTitle': 'Sekcje panelu',
|
||||
'chat.workStatus.sections.dialogDescription': 'Wybierz, co pokazuje panel stanu pracy. Ukryte sekcje zachowują swoje dane — po prostu nie są wyświetlane.',
|
||||
'chat.workStatus.sections.allHidden': 'Nie wybrano żadnych sekcji',
|
||||
'chat.workStatus.sections.showAll': 'Pokaż wszystkie',
|
||||
'chat.workStatus.sections.noneWarning': 'Panel będzie wyświetlany jako pusty.',
|
||||
'header.workStatusPanel.toggleAria': 'Przełącz panel stanu pracy',
|
||||
'header.workStatusPanel.hide': 'Ukryj stan pracy',
|
||||
'header.workStatusPanel.show': 'Pokaż stan pracy',
|
||||
|
||||
@@ -3004,6 +3004,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': 'Escolher seções',
|
||||
'chat.workStatus.sections.dialogTitle': 'Seções do painel',
|
||||
'chat.workStatus.sections.dialogDescription': 'Escolha o que o painel de status mostra. Seções ocultas mantêm seus dados — apenas não aparecem no painel.',
|
||||
'chat.workStatus.sections.allHidden': 'Nenhuma seção selecionada',
|
||||
'chat.workStatus.sections.showAll': 'Mostrar todas',
|
||||
'chat.workStatus.sections.noneWarning': 'O painel aparecerá vazio.',
|
||||
'header.workStatusPanel.toggleAria': 'Alternar painel de status',
|
||||
'header.workStatusPanel.hide': 'Ocultar status do trabalho',
|
||||
'header.workStatusPanel.show': 'Mostrar status do trabalho',
|
||||
|
||||
@@ -3004,6 +3004,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': 'Обрати секції',
|
||||
'chat.workStatus.sections.dialogTitle': 'Секції панелі',
|
||||
'chat.workStatus.sections.dialogDescription': 'Обери, що показує панель стану роботи. Приховані секції зберігають свої дані — вони просто не відображаються.',
|
||||
'chat.workStatus.sections.allHidden': 'Жодної секції не вибрано',
|
||||
'chat.workStatus.sections.showAll': 'Показати всі',
|
||||
'chat.workStatus.sections.noneWarning': 'Панель відображатиметься порожньою.',
|
||||
'header.workStatusPanel.toggleAria': 'Перемкнути панель стану роботи',
|
||||
'header.workStatusPanel.hide': 'Сховати стан роботи',
|
||||
'header.workStatusPanel.show': 'Показати стан роботи',
|
||||
|
||||
@@ -3004,6 +3004,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': '选择板块',
|
||||
'chat.workStatus.sections.dialogTitle': '面板板块',
|
||||
'chat.workStatus.sections.dialogDescription': '选择工作状态面板显示的内容。隐藏的板块仍保留数据,只是不再显示。',
|
||||
'chat.workStatus.sections.allHidden': '未选择任何部分',
|
||||
'chat.workStatus.sections.showAll': '全部显示',
|
||||
'chat.workStatus.sections.noneWarning': '面板将显示为空。',
|
||||
'header.workStatusPanel.toggleAria': '切换工作状态面板',
|
||||
'header.workStatusPanel.hide': '隐藏工作状态',
|
||||
'header.workStatusPanel.show': '显示工作状态',
|
||||
|
||||
@@ -3003,6 +3003,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.sections.open': '選擇區塊',
|
||||
'chat.workStatus.sections.dialogTitle': '面板區塊',
|
||||
'chat.workStatus.sections.dialogDescription': '選擇工作狀態面板顯示的內容。隱藏的區塊仍保留資料,只是不再顯示。',
|
||||
'chat.workStatus.sections.allHidden': '未選擇任何部分',
|
||||
'chat.workStatus.sections.showAll': '全部顯示',
|
||||
'chat.workStatus.sections.noneWarning': '面板將顯示為空。',
|
||||
'header.workStatusPanel.toggleAria': '切換工作狀態面板',
|
||||
'header.workStatusPanel.hide': '隱藏工作狀態',
|
||||
'header.workStatusPanel.show': '顯示工作狀態',
|
||||
|
||||
Reference in New Issue
Block a user