test: cover work-status empty panel recovery

This commit is contained in:
Bohdan Triapitsyn
2026-08-10 23:31:58 +03:00
parent 02872ff9d3
commit a0207e3857
5 changed files with 83 additions and 11 deletions
@@ -203,6 +203,11 @@ section decides for itself that it has nothing to say, so they report through
`presenceContext.ts` and the panel collapses when none rendered. Deriving that `presenceContext.ts` and the panel collapses when none rendered. Deriving that
at the panel level would mean duplicating every data source the sections read. at the panel level would mean duplicating every data source the sections read.
There is one deliberate exception: when the user hides every section, the card
stays visible with a localized empty state and section controls. Collapsing that
state would also hide the only recovery path. A panel with enabled sections but
no data still follows the presence reports and collapses as before.
The scroll offset resets on session change: restoring one session's offset into The scroll offset resets on session change: restoring one session's offset into
another's shorter panel lands somewhere arbitrary. another's shorter panel lands somewhere arbitrary.
@@ -2,6 +2,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility'; import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility';
import { WorkStatusGoalRow } from './WorkStatusGoalRow'; import { WorkStatusGoalRow } from './WorkStatusGoalRow';
@@ -13,7 +14,11 @@ import { WorkStatusMcpSection } from './WorkStatusMcpSection';
import { WorkStatusPinnedSection } from './WorkStatusPinnedSection'; import { WorkStatusPinnedSection } from './WorkStatusPinnedSection';
import { WorkStatusContextSection } from './WorkStatusContextSection'; import { WorkStatusContextSection } from './WorkStatusContextSection';
import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog'; import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog';
import { areAllWorkStatusSectionsHidden, isWorkStatusSectionVisible } from './sections'; import {
areAllWorkStatusSectionsHidden,
getWorkStatusPanelPresentation,
isWorkStatusSectionVisible,
} from './sections';
import { WorkStatusPresenceProvider } from './presence'; import { WorkStatusPresenceProvider } from './presence';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
@@ -89,7 +94,12 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
// for the transient "no data yet" state so the panel doesn't flash a bare // for the transient "no data yet" state so the panel doesn't flash a bare
// bordered card on first mount. // bordered card on first mount.
const allSectionsHidden = areAllWorkStatusSectionsHidden(hiddenSections); const allSectionsHidden = areAllWorkStatusSectionsHidden(hiddenSections);
const interactive = visible && (renderedSections > 0 || allSectionsHidden); const { interactive, showEmptyState } = getWorkStatusPanelPresentation({
visible,
contentMounted,
renderedSections,
allSectionsHidden,
});
React.useEffect(() => { React.useEffect(() => {
if (visible) { if (visible) {
setContentMounted(true); setContentMounted(true);
@@ -251,16 +261,17 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
</WorkStatusPresenceProvider> </WorkStatusPresenceProvider>
) : null} ) : null}
{contentMounted && allSectionsHidden ? ( {showEmptyState ? (
<div className="flex flex-col items-center justify-center px-4 py-8 text-center"> <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> <span className="text-sm text-muted-foreground">{t('chat.workStatus.sections.allHidden')}</span>
<button <Button
type="button" variant="link"
size="xs"
onClick={() => setSectionsDialogOpen(true)} onClick={() => setSectionsDialogOpen(true)}
className="mt-2 text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground" className="mt-2 normal-case text-muted-foreground hover:text-foreground"
> >
{t('chat.workStatus.sections.open')} {t('chat.workStatus.sections.open')}
</button> </Button>
</div> </div>
) : null} ) : null}
@@ -2,6 +2,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection'; import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -63,13 +64,14 @@ export const WorkStatusSectionsDialog: React.FC<{
{noneVisible ? ( {noneVisible ? (
<span className="text-xs text-destructive">{t('chat.workStatus.sections.noneWarning')}</span> <span className="text-xs text-destructive">{t('chat.workStatus.sections.noneWarning')}</span>
) : <span />} ) : <span />}
<button <Button
type="button" variant="link"
size="xs"
onClick={handleShowAll} onClick={handleShowAll}
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground" className="normal-case text-muted-foreground hover:text-foreground"
> >
{t('chat.workStatus.sections.showAll')} {t('chat.workStatus.sections.showAll')}
</button> </Button>
</div> </div>
) : null} ) : null}
</DialogContent> </DialogContent>
@@ -3,6 +3,7 @@ import {
WORK_STATUS_SECTION_IDS, WORK_STATUS_SECTION_IDS,
WORK_STATUS_SECTION_LABEL_KEYS, WORK_STATUS_SECTION_LABEL_KEYS,
areAllWorkStatusSectionsHidden, areAllWorkStatusSectionsHidden,
getWorkStatusPanelPresentation,
isWorkStatusSectionVisible, isWorkStatusSectionVisible,
sanitizeWorkStatusHiddenSections, sanitizeWorkStatusHiddenSections,
} from './sections'; } from './sections';
@@ -62,6 +63,44 @@ describe('areAllWorkStatusSectionsHidden', () => {
}); });
}); });
describe('getWorkStatusPanelPresentation', () => {
test('keeps a visible all-hidden panel interactive and renders its recovery state', () => {
expect(getWorkStatusPanelPresentation({
visible: true,
contentMounted: true,
renderedSections: 0,
allSectionsHidden: true,
})).toEqual({ interactive: true, showEmptyState: true });
});
test('covers the optimistic fresh-mount count when all sections are hidden', () => {
expect(getWorkStatusPanelPresentation({
visible: true,
contentMounted: true,
renderedSections: 1,
allSectionsHidden: true,
})).toEqual({ interactive: true, showEmptyState: true });
});
test('preserves collapse when no section has data but sections remain enabled', () => {
expect(getWorkStatusPanelPresentation({
visible: true,
contentMounted: true,
renderedSections: 0,
allSectionsHidden: false,
})).toEqual({ interactive: false, showEmptyState: false });
});
test('does not expose controls or the empty state during a hidden collapse', () => {
expect(getWorkStatusPanelPresentation({
visible: false,
contentMounted: false,
renderedSections: 0,
allSectionsHidden: true,
})).toEqual({ interactive: false, showEmptyState: false });
});
});
describe('sanitizeWorkStatusHiddenSections', () => { describe('sanitizeWorkStatusHiddenSections', () => {
test('keeps known ids and drops everything else', () => { test('keeps known ids and drops everything else', () => {
expect(sanitizeWorkStatusHiddenSections(['usage', 'nope', 42, null, 'tasks'])) expect(sanitizeWorkStatusHiddenSections(['usage', 'nope', 42, null, 'tasks']))
@@ -60,6 +60,21 @@ export const areAllWorkStatusSectionsHidden = (
): boolean => ): boolean =>
hidden != null && WORK_STATUS_SECTION_IDS.every((id) => hidden.includes(id)); hidden != null && WORK_STATUS_SECTION_IDS.every((id) => hidden.includes(id));
export const getWorkStatusPanelPresentation = ({
visible,
contentMounted,
renderedSections,
allSectionsHidden,
}: {
visible: boolean;
contentMounted: boolean;
renderedSections: number;
allSectionsHidden: boolean;
}): { interactive: boolean; showEmptyState: boolean } => ({
interactive: visible && (renderedSections > 0 || allSectionsHidden),
showEmptyState: contentMounted && allSectionsHidden,
});
export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => { export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
const seen = new Set<WorkStatusSectionId>(); const seen = new Set<WorkStatusSectionId>();