fix(desktop): index session history without narrating it

The sidebar reported "Optimizing history N/M" above the session list while
the SQLite index built. Indexing is background housekeeping the user cannot
act on, and the counter sat there for the whole build.

Drop the visible progress row and narrow the live region to `degraded` —
the one state a user can perceive, where history really is served the slow
way. Building/ready/off now stay silent for screen readers too, so the
behavior is the same regardless of how the sidebar is read.

The four locale strings this leaves unused are deleted in all five
languages, with a resurrection guard alongside the existing one for the
removed installed-skills keys.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-28 01:17:38 +08:00
parent 8bad67e590
commit 617a0a334d
8 changed files with 53 additions and 54 deletions

View File

@ -35,10 +35,6 @@ vi.mock('../../i18n', () => ({
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed',
'sidebar.refreshSessions': 'Refresh sessions',
'sidebar.indexBuilding': 'Optimizing history',
'sidebar.indexBuildingProgress': 'Optimizing history {indexed}/{discovered}',
'sidebar.indexReady': 'History optimization complete',
'sidebar.indexOff': 'History optimization inactive',
'sidebar.indexDegraded': 'Using standard history loading',
'search.global.trigger': 'Search chats',
'sidebar.projects': 'Projects',
@ -1294,7 +1290,9 @@ describe('Sidebar', () => {
expect(screen.queryByText('No sessions')).not.toBeInTheDocument()
})
it('shows quiet building progress while keeping indexed rows visible', () => {
// Indexing is background housekeeping the user cannot act on, so a partially
// built index must look exactly like a finished one: rows visible, no counter.
it('keeps indexed rows visible while building without surfacing progress', () => {
useSessionStore.setState({
sessions: [makeSession('indexed-row', 'Indexed row', '/workspace/alpha', '2026-07-15T00:00:00.000Z')],
indexStatus: {
@ -1313,7 +1311,9 @@ describe('Sidebar', () => {
render(<Sidebar />)
expect(screen.getByRole('button', { name: /Indexed row/ })).toBeInTheDocument()
expect(screen.getByTestId('sidebar-index-progress')).toHaveTextContent('Optimizing history 2/10')
expect(screen.queryByTestId('sidebar-index-progress')).not.toBeInTheDocument()
expect(screen.queryByText(/2\s*\/\s*10/)).not.toBeInTheDocument()
expect(screen.getByRole('status')).toBeEmptyDOMElement()
})
it.each(['ready', 'off'] as const)('hides visible index status when state is %s', (state) => {
@ -1565,14 +1565,17 @@ describe('Sidebar', () => {
}
})
it('announces state transitions without exposing numeric progress to the live region', () => {
// The live region exists for the one transition a user can perceive: history
// is being served the slow way. Building/ready/off are silent there too, so a
// screen reader is not told about work that needs no reaction.
it.each(['building', 'ready', 'off'] as const)('stays silent in the live region while %s', (state) => {
useSessionStore.setState({
sessions: [makeSession('live-row', 'Live row', '/workspace/alpha', '2026-07-15T00:00:00.000Z')],
indexStatus: {
mode: 'on',
state: 'building',
mode: state === 'off' ? 'off' : 'on',
state,
discovered: 10,
indexed: 2,
indexed: state === 'building' ? 2 : 10,
degradedSources: 0,
databaseBytes: 4096,
walBytes: 0,
@ -1583,10 +1586,31 @@ describe('Sidebar', () => {
render(<Sidebar />)
expect(screen.getByRole('status')).toBeEmptyDOMElement()
})
it('announces the degraded fallback in the live region', () => {
useSessionStore.setState({
sessions: [makeSession('live-row', 'Live row', '/workspace/alpha', '2026-07-15T00:00:00.000Z')],
indexStatus: {
mode: 'on',
state: 'degraded',
discovered: 10,
indexed: 2,
degradedSources: 1,
databaseBytes: 4096,
walBytes: 0,
lastUpdatedAt: '2026-07-15T00:00:00.000Z',
lastErrorCode: 'source_unreadable',
},
})
render(<Sidebar />)
const liveRegion = screen.getByRole('status')
expect(liveRegion).toHaveTextContent('Optimizing history')
expect(liveRegion).toHaveTextContent('Using standard history loading')
expect(liveRegion).not.toHaveTextContent('2/10')
expect(screen.getByTestId('sidebar-index-progress')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByTestId('sidebar-index-degraded')).toHaveAttribute('aria-hidden', 'true')
})
it('refreshes sessions manually and through low-frequency visible polling', async () => {

View File

@ -190,17 +190,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}, [hiddenProjectKeys, orderedProjectGroups])
const showInitialLoading = isLoading && sessions.length === 0
const showRefreshLoading = showInitialLoading
const showIndexBuilding = indexStatus?.state === 'building' && sessions.length > 0
// Index building/ready/off are implementation details of how the list is
// loaded, not something the user acts on, so they stay silent in both the
// visible sidebar and the live region. Only `degraded` is announced: there
// the list really is served a different way, which the user can perceive.
const showIndexDegraded = indexStatus?.state === 'degraded'
const indexAnnouncement = indexStatus
? t(indexStatus.state === 'building'
? 'sidebar.indexBuilding'
: indexStatus.state === 'ready'
? 'sidebar.indexReady'
: indexStatus.state === 'degraded'
? 'sidebar.indexDegraded'
: 'sidebar.indexOff')
: ''
const indexAnnouncement = showIndexDegraded ? t('sidebar.indexDegraded') : ''
const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions])
const selectedCount = selectedSessionIds.size
const sessionsById = useMemo(
@ -889,18 +884,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{indexAnnouncement}
</div>
{showIndexBuilding && (
<div
data-testid="sidebar-index-progress"
aria-hidden="true"
className="mx-4 mb-1 flex-none text-[11px] leading-5 text-[var(--color-text-tertiary)]"
>
{t('sidebar.indexBuildingProgress', {
indexed: indexStatus.indexed,
discovered: indexStatus.discovered,
})}
</div>
)}
{showIndexDegraded && (
<div
data-testid="sidebar-index-degraded"

View File

@ -77,4 +77,16 @@ describe('useTranslation', () => {
expect(resurrected, `${name} still defines removed installed-skills keys`).toEqual([])
}
})
// Index building/ready/off went silent: the sidebar no longer tells anyone
// that history is being indexed. Keeping the strings around invites a future
// change to wire them back into the UI, so they must stay deleted.
it('carries no key for the silent index states', () => {
for (const [name, locale] of Object.entries(locales)) {
const resurrected = Object.keys(locale).filter(
key => key.startsWith('sidebar.index') && key !== 'sidebar.indexDegraded',
)
expect(resurrected, `${name} still defines silent index-status keys`).toEqual([])
}
})
})

View File

@ -48,10 +48,6 @@ export const en = {
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed to load',
'sidebar.refreshSessions': 'Refresh sessions',
'sidebar.indexBuilding': 'Optimizing history',
'sidebar.indexBuildingProgress': 'Optimizing history {indexed}/{discovered}',
'sidebar.indexReady': 'History optimization complete',
'sidebar.indexOff': 'History optimization inactive',
'sidebar.indexDegraded': 'Using standard history loading',
'search.global.trigger': 'Search chats',
'search.global.placeholder': 'Search all chats…',

View File

@ -50,10 +50,6 @@ export const jp: Record<TranslationKey, string> = {
'sidebar.noMatching': '一致するセッションがありません',
'sidebar.sessionListFailed': 'セッション一覧の読み込みに失敗しました',
'sidebar.refreshSessions': 'セッションを更新',
'sidebar.indexBuilding': '履歴を最適化しています',
'sidebar.indexBuildingProgress': '履歴を最適化しています {indexed}/{discovered}',
'sidebar.indexReady': '履歴の最適化が完了しました',
'sidebar.indexOff': '履歴の最適化は無効です',
'sidebar.indexDegraded': '標準の履歴読み込みを使用しています',
'search.global.trigger': 'チャットを検索',
'search.global.placeholder': 'すべてのチャットを検索…',

View File

@ -50,10 +50,6 @@ export const kr: Record<TranslationKey, string> = {
'sidebar.noMatching': '일치하는 세션이 없습니다',
'sidebar.sessionListFailed': '세션 목록을 불러오지 못했습니다',
'sidebar.refreshSessions': '세션 새로 고침',
'sidebar.indexBuilding': '기록을 최적화하는 중',
'sidebar.indexBuildingProgress': '기록을 최적화하는 중 {indexed}/{discovered}',
'sidebar.indexReady': '기록 최적화가 완료되었습니다',
'sidebar.indexOff': '기록 최적화가 비활성화되었습니다',
'sidebar.indexDegraded': '표준 기록 불러오기를 사용하고 있습니다',
'search.global.trigger': '대화 검색',
'search.global.placeholder': '모든 대화 검색…',

View File

@ -50,10 +50,6 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.noMatching': '沒有匹配的會話',
'sidebar.sessionListFailed': '會話列表載入失敗',
'sidebar.refreshSessions': '重新整理會話列表',
'sidebar.indexBuilding': '正在最佳化歷史記錄',
'sidebar.indexBuildingProgress': '正在最佳化歷史記錄 {indexed}/{discovered}',
'sidebar.indexReady': '歷史記錄最佳化完成',
'sidebar.indexOff': '歷史記錄最佳化未啟用',
'sidebar.indexDegraded': '正在使用標準歷史記錄載入',
'search.global.trigger': '搜尋聊天',
'search.global.placeholder': '搜尋全部聊天內容…',

View File

@ -50,10 +50,6 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.noMatching': '没有匹配的会话',
'sidebar.sessionListFailed': '会话列表加载失败',
'sidebar.refreshSessions': '刷新会话列表',
'sidebar.indexBuilding': '正在优化历史记录',
'sidebar.indexBuildingProgress': '正在优化历史记录 {indexed}/{discovered}',
'sidebar.indexReady': '历史记录优化完成',
'sidebar.indexOff': '历史记录优化未启用',
'sidebar.indexDegraded': '正在使用标准历史记录加载',
'search.global.trigger': '搜索聊天',
'search.global.placeholder': '搜索全部聊天内容…',