Skip to content

Commit c70b4c5

Browse files
committed
feat(desktop): cap session tabs, skip restoring them, reopen closed ones (#1550)
Session tabs pile up when many chats run in parallel. Settings → General gains a Tabs section, and every default keeps today's behavior: - Session tabs to keep (no limit / 5 / 8 / 12 / 20). Checked only when a chat tab is added or the limit is lowered, so a tab never disappears while nobody is acting. The tab looked at least recently goes first; the active tab, running or waiting chats, unsent drafts, team leads and chats another open tab views are kept, even past the limit. - Restore tabs on launch. Off starts with an empty strip and keeps the saved tabs, so turning it back on restores them. - Reopen Closed Tab in the tab menu brings back the last ten chat tabs closed in this run, skipping deleted sessions. Cmd/Ctrl+Shift+T stays with the workspace panel, which already uses it. Auto-closing reuses the tab strip's own close path, now in stores/tabClose.ts, so it never stops or deletes a session. Not active in the phone and tablet shells.
1 parent af819ad commit c70b4c5

24 files changed

Lines changed: 1018 additions & 98 deletions

‎desktop/src/App.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { RemoteAccessGate } from './pages/RemoteAccess'
66
import { isPublicAccessRuntime } from './lib/publicAccessRuntime'
77
import { useChatStore } from './stores/chatStore'
88
import { installHiddenSessionRelease } from './stores/hiddenSessionRelease'
9+
import { installSessionTabLimit } from './stores/sessionTabLimit'
910
import { useMobileShellLayout } from './components/mobile/mobileShellLayout'
1011
import { isDesktopRuntime } from './lib/desktopRuntime'
1112

@@ -24,6 +25,11 @@ function ConnectedApp() {
2425
}),
2526
[shellLayout],
2627
)
28+
// Phones and tablets keep one page on screen; their tabs are only a way back.
29+
useEffect(
30+
() => (shellLayout === 'desktop' ? installSessionTabLimit() : undefined),
31+
[shellLayout],
32+
)
2733
useEffect(() => {
2834
let cleanup: (() => void) | undefined
2935
let cancelled = false

‎desktop/src/components/layout/TabBar.test.tsx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ vi.mock('../../i18n', () => ({
175175
'tabs.closeLeft': 'Close Left',
176176
'tabs.closeRight': 'Close Right',
177177
'tabs.closeAll': 'Close All',
178+
'tabs.reopenClosed': 'Reopen Closed Tab',
178179
'tabs.closeConfirmTitle': 'Session Running',
179180
'tabs.closeConfirmMessage': 'Still running',
180181
'tabs.closeConfirmKeep': 'Keep Running',
@@ -3229,6 +3230,55 @@ describe('TabBar', () => {
32293230
})
32303231
})
32313232

3233+
it('reopens a closed chat tab from the tab menu, and offers it only after one was closed', async () => {
3234+
const { TabBar } = await import('./TabBar')
3235+
const { useTabStore } = await import('../../stores/tabStore')
3236+
const { useChatStore } = await import('../../stores/chatStore')
3237+
const { useSessionStore } = await import('../../stores/sessionStore')
3238+
const connectToSession = vi.fn()
3239+
const disconnectSession = vi.fn()
3240+
3241+
useSessionStore.setState({
3242+
sessions: [
3243+
{ id: 'tab-a', title: 'Session A', createdAt: '2026-10-01T00:00:00.000Z', modifiedAt: '2026-10-01T00:00:00.000Z', messageCount: 3, projectPath: '-repo', workDir: '/repo', workDirExists: true },
3244+
],
3245+
} as never)
3246+
useTabStore.setState({
3247+
tabs: [
3248+
{ sessionId: 'tab-a', title: 'Session A', type: 'session', status: 'idle' },
3249+
{ sessionId: 'tab-b', title: 'Session B', type: 'session', status: 'idle' },
3250+
],
3251+
activeTabId: 'tab-b',
3252+
closedSessionTabs: [],
3253+
})
3254+
useChatStore.setState({
3255+
sessions: { 'tab-a': makeChatSession('idle'), 'tab-b': makeChatSession('idle') },
3256+
connectToSession,
3257+
disconnectSession,
3258+
} as Partial<ReturnType<typeof useChatStore.getState>>)
3259+
3260+
await act(async () => {
3261+
render(<TabBar />)
3262+
})
3263+
3264+
fireEvent.contextMenu(screen.getByText('Session B'))
3265+
expect(screen.getByText('Reopen Closed Tab')).toBeDisabled()
3266+
3267+
fireEvent.click(screen.getByText('Close Others'))
3268+
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-b'])
3269+
expect(disconnectSession).toHaveBeenCalledWith('tab-a')
3270+
3271+
fireEvent.contextMenu(screen.getByText('Session B'))
3272+
await act(async () => {
3273+
fireEvent.click(screen.getByText('Reopen Closed Tab'))
3274+
})
3275+
3276+
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-b', 'tab-a'])
3277+
expect(useTabStore.getState().activeTabId).toBe('tab-a')
3278+
expect(connectToSession).toHaveBeenCalledWith('tab-a')
3279+
expect(screen.queryByText('Reopen Closed Tab')).not.toBeInTheDocument()
3280+
})
3281+
32323282
it('shows a running marker on tabs from tab status, live chat state, or background tasks', async () => {
32333283
const { TabBar } = await import('./TabBar')
32343284
const { useTabStore } = await import('../../stores/tabStore')

‎desktop/src/components/layout/TabBar.tsx‎

Lines changed: 18 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
11
import { forwardRef, useMemo, useRef, useState, useEffect, useLayoutEffect, useCallback, useId } from 'react'
22
import { useShallow } from 'zustand/react/shallow'
33
import {
4-
SCHEDULED_TAB_ID,
5-
SETTINGS_TAB_ID,
6-
MARKET_TAB_ID,
7-
CONNECTORS_TAB_ID,
8-
SUBAGENT_TAB_PREFIX,
9-
TEAM_MEMBER_TAB_PREFIX,
10-
TEAM_TAB_PREFIX,
11-
TERMINAL_TAB_PREFIX,
12-
WORKBENCH_TAB_PREFIX,
134
useTabStore,
145
type Tab,
156
type TabType,
167
} from '../../stores/tabStore'
8+
import {
9+
closeTabsWithPolicy,
10+
getRunningSessionIds,
11+
isSessionTab,
12+
isSessionTabId,
13+
reopenClosedSessionTab,
14+
} from '../../stores/tabClose'
1715
import { useChatStore } from '../../stores/chatStore'
1816
import { useSessionStore } from '../../stores/sessionStore'
1917
import type { UIMessage } from '../../types/chat'
20-
import { isPlaceholderSessionTitle } from '../../lib/sessionTitle'
2118
import { useWorkspaceStore } from '../../stores/workspaceStore'
22-
import { releaseWorkspaceSession } from '../../lib/workspace/releaseSession'
2319
import { useCLITaskStore } from '../../stores/cliTaskStore'
2420
import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore'
2521
import { StatusDot } from '@/components/ui/Badge'
@@ -28,7 +24,7 @@ import { tabChipClass, tabChipLabelClass } from '@/components/ui/tabChip'
2824
import { useDismissable } from '@/hooks/useDismissable'
2925
import { useTranslation } from '../../i18n'
3026
import { getDesktopHost } from '../../lib/desktopHost'
31-
import { hasRunningBackgroundTasks, listRunningBackgroundTasks } from '../../lib/backgroundTasks'
27+
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
3228
import { collectAttentionIds, nextAttentionSessionId } from '../../lib/sessionAttention'
3329
import { SessionAttentionMark } from './SessionAttentionMark'
3430
import { TabAttentionJump } from './TabAttentionJump'
@@ -110,32 +106,11 @@ type PendingCloseRequest = {
110106
runningSessionIds: string[]
111107
}
112108

113-
function isSessionTab(tab: Tab | null) {
114-
if (!tab) return false
115-
const tabType = (tab as Partial<Tab>).type
116-
if (tabType === 'session') return true
117-
if (tabType) return false
118-
return isSessionTabId(tab.sessionId)
119-
}
120-
121-
function isSessionTabId(tabId: string | null) {
122-
if (!tabId) return false
123-
return tabId !== SETTINGS_TAB_ID &&
124-
tabId !== SCHEDULED_TAB_ID &&
125-
tabId !== MARKET_TAB_ID &&
126-
tabId !== CONNECTORS_TAB_ID &&
127-
!tabId.startsWith(TERMINAL_TAB_PREFIX) &&
128-
!tabId.startsWith(WORKBENCH_TAB_PREFIX) &&
129-
!tabId.startsWith(SUBAGENT_TAB_PREFIX) &&
130-
!tabId.startsWith(TEAM_TAB_PREFIX) &&
131-
!tabId.startsWith(TEAM_MEMBER_TAB_PREFIX)
132-
}
133-
134109
export function TabBar() {
135110
const tabs = useTabStore((s) => s.tabs)
136111
const activeTabId = useTabStore((s) => s.activeTabId)
137112
const setActiveTab = useTabStore((s) => s.setActiveTab)
138-
const closeTab = useTabStore((s) => s.closeTab)
113+
const canReopenClosedTab = useTabStore((s) => s.closedSessionTabs.length > 0)
139114
const sessionTabIds = useMemo(
140115
() => tabs.filter((tab) => isSessionTab(tab)).map((tab) => tab.sessionId),
141116
[tabs],
@@ -156,7 +131,6 @@ export function TabBar() {
156131
// The waiting tabs other than the one on screen: the places a jump can take
157132
// you, and so the number on the button that offers it.
158133
const otherAttentionCount = attentionList.filter((sessionId) => sessionId !== activeTabId).length
159-
const disconnectSession = useChatStore((s) => s.disconnectSession)
160134
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
161135
const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId)
162136
const activeSession = useSessionStore((state) =>
@@ -426,65 +400,6 @@ export function TabBar() {
426400
el.scrollBy({ left: direction === 'left' ? -step : step, behavior: 'smooth' })
427401
}
428402

429-
const closeTabWithCleanup = useCallback((tab: Tab) => {
430-
if (isSessionTab(tab)) {
431-
releaseWorkspaceSession(tab.sessionId)
432-
}
433-
closeTab(tab.sessionId)
434-
}, [closeTab])
435-
436-
const getRunningSessionIds = useCallback((targetTabs: Tab[]) => {
437-
const chatSessions = useChatStore.getState().sessions
438-
return targetTabs
439-
.filter((tab) => isSessionTab(tab))
440-
.filter((tab) => {
441-
const sessionState = chatSessions[tab.sessionId]
442-
return !!sessionState &&
443-
(sessionState.chatState !== 'idle' || hasRunningBackgroundTasks(sessionState.backgroundAgentTasks))
444-
})
445-
.map((tab) => tab.sessionId)
446-
}, [])
447-
448-
const closeTabsWithPolicy = useCallback((targetTabs: Tab[], runningSessionIds: string[], stopRunning: boolean) => {
449-
const runningSessionSet = new Set(runningSessionIds)
450-
451-
for (const tab of targetTabs) {
452-
if (isSessionTab(tab)) {
453-
const isRunning = runningSessionSet.has(tab.sessionId)
454-
if (isRunning && stopRunning) {
455-
const chat = useChatStore.getState()
456-
const runningTasks = listRunningBackgroundTasks(chat.sessions[tab.sessionId]?.backgroundAgentTasks)
457-
chat.stopGeneration(tab.sessionId)
458-
// The dialog counts background tasks as the session running, but
459-
// stopGeneration only reaches the foreground turn and Agent tasks (and
460-
// marks the latter as stopping, which stopBackgroundTask skips). A shell
461-
// command would outlive "Stop & Close" and keep the reopened session
462-
// running. Both must go out before disconnectSession closes the socket.
463-
for (const task of runningTasks) {
464-
chat.stopBackgroundTask(tab.sessionId, task.taskId)
465-
}
466-
}
467-
if (!isRunning || stopRunning) {
468-
// Auto-delete only when both server metadata and the loaded transcript
469-
// confirm this is an empty placeholder. Missing state can mean a timed-out
470-
// recovery load, so treating it as empty risks deleting a real session.
471-
const sessionEntry = useSessionStore.getState().sessions.find((s) => s.id === tab.sessionId)
472-
const chatEntry = useChatStore.getState().sessions[tab.sessionId]
473-
if (
474-
sessionEntry?.messageCount === 0 &&
475-
isPlaceholderSessionTitle(sessionEntry.title) &&
476-
chatEntry?.historyStatus === 'ready' &&
477-
chatEntry.messages.length === 0
478-
) {
479-
void useSessionStore.getState().deleteSession(tab.sessionId)
480-
}
481-
disconnectSession(tab.sessionId)
482-
}
483-
}
484-
closeTabWithCleanup(tab)
485-
}
486-
}, [closeTabWithCleanup, disconnectSession])
487-
488403
const requestCloseTabs = useCallback((targetTabs: Tab[]) => {
489404
if (targetTabs.length === 0) return
490405
const runningSessionIds = getRunningSessionIds(targetTabs)
@@ -495,7 +410,7 @@ export function TabBar() {
495410
}
496411

497412
closeTabsWithPolicy(targetTabs, [], false)
498-
}, [closeTabsWithPolicy, getRunningSessionIds])
413+
}, [])
499414

500415
const handleClose = (sessionId: string) => {
501416
const tab = tabs.find((t) => t.sessionId === sessionId)
@@ -877,6 +792,14 @@ export function TabBar() {
877792
>
878793
{t('tabs.closeAll')}
879794
</button>
795+
<div className="mx-1 my-1 border-t border-[var(--color-border)]" />
796+
<button
797+
disabled={!canReopenClosedTab}
798+
onClick={() => { setContextMenu(null); void reopenClosedSessionTab() }}
799+
className="flex h-8 w-full items-center rounded-[var(--radius-sm)] px-2.5 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-default disabled:text-[var(--color-text-tertiary)] disabled:hover:bg-transparent"
800+
>
801+
{t('tabs.reopenClosed')}
802+
</button>
880803
</div>
881804
)}
882805

‎desktop/src/i18n/locales/en.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3771,6 +3771,16 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
37713771
'tabs.closeLeft': 'Close to the Left',
37723772
'tabs.closeRight': 'Close to the Right',
37733773
'tabs.closeAll': 'Close All',
3774+
'tabs.reopenClosed': 'Reopen Closed Tab',
3775+
'settings.tabs.title': 'Tabs',
3776+
'settings.tabs.description': 'How the session tabs at the top behave. Closing a tab never deletes the session: find it again in the sidebar, or right-click a tab and choose Reopen Closed Tab.',
3777+
'settings.tabs.limit': 'Session tabs to keep',
3778+
'settings.tabs.limitHint': 'When there are more, the tab you looked at least recently is closed. Tabs that are running, waiting for you, or hold an unsent message are kept.',
3779+
'settings.tabs.limitUnlimited': 'No limit',
3780+
'settings.tabs.limitCount': '{count} tabs',
3781+
'settings.tabs.restoreOnLaunch': 'Restore tabs on launch',
3782+
'settings.tabs.restoreOnLaunchHintOn': 'Tabs left open last time come back when the app starts.',
3783+
'settings.tabs.restoreOnLaunchHintOff': 'The app starts with no tabs open. Your sessions stay in the sidebar.',
37743784
'tabs.closeConfirmTitle': 'Session Running',
37753785
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
37763786
'tabs.closeConfirmKeep': 'Keep Running',

‎desktop/src/i18n/locales/jp.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3772,6 +3772,16 @@ export const jp: Record<TranslationKey, string> = {
37723772
'tabs.closeLeft': '左側を閉じる',
37733773
'tabs.closeRight': '右側を閉じる',
37743774
'tabs.closeAll': 'すべて閉じる',
3775+
'tabs.reopenClosed': '閉じたタブを再度開く',
3776+
'settings.tabs.title': 'タブ',
3777+
'settings.tabs.description': '上部のセッションタブの動作を設定します。タブを閉じてもセッションは削除されません。サイドバーから、またはタブを右クリックして「閉じたタブを再度開く」で戻せます。',
3778+
'settings.tabs.limit': '保持するセッションタブ数',
3779+
'settings.tabs.limitHint': '上限を超えると、最も長く表示していないタブを自動で閉じます。実行中、対応待ち、未送信の入力があるタブは閉じません。',
3780+
'settings.tabs.limitUnlimited': '無制限',
3781+
'settings.tabs.limitCount': '{count} 個',
3782+
'settings.tabs.restoreOnLaunch': '起動時に前回のタブを復元',
3783+
'settings.tabs.restoreOnLaunchHintOn': 'アプリを再起動すると、前回開いていたタブがそのまま戻ります。',
3784+
'settings.tabs.restoreOnLaunchHintOff': 'アプリはタブなしで起動します。セッションはサイドバーに残ります。',
37753785
'tabs.closeConfirmTitle': 'セッション実行中',
37763786
'tabs.closeConfirmMessage': 'このセッションはまだ実行中です。どうしますか?',
37773787
'tabs.closeConfirmKeep': '実行を継続',

‎desktop/src/i18n/locales/kr.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3774,6 +3774,16 @@ export const kr: Record<TranslationKey, string> = {
37743774
'tabs.closeLeft': '왼쪽 탭 닫기',
37753775
'tabs.closeRight': '오른쪽 탭 닫기',
37763776
'tabs.closeAll': '모두 닫기',
3777+
'tabs.reopenClosed': '닫은 탭 다시 열기',
3778+
'settings.tabs.title': '탭',
3779+
'settings.tabs.description': '상단 세션 탭의 동작을 설정합니다. 탭을 닫아도 세션은 삭제되지 않으며, 사이드바에서 또는 탭을 우클릭해 「닫은 탭 다시 열기」로 되찾을 수 있습니다.',
3780+
'settings.tabs.limit': '유지할 세션 탭 수',
3781+
'settings.tabs.limitHint': '초과하면 가장 오래 보지 않은 세션 탭을 자동으로 닫습니다. 실행 중이거나 응답을 기다리거나 보내지 않은 입력이 있는 탭은 닫지 않습니다.',
3782+
'settings.tabs.limitUnlimited': '제한 없음',
3783+
'settings.tabs.limitCount': '{count}개',
3784+
'settings.tabs.restoreOnLaunch': '시작할 때 이전 탭 복원',
3785+
'settings.tabs.restoreOnLaunchHintOn': '앱을 다시 열면 지난번에 열려 있던 탭이 그대로 돌아옵니다.',
3786+
'settings.tabs.restoreOnLaunchHintOff': '앱이 탭 없이 시작됩니다. 세션은 사이드바에 그대로 있습니다.',
37773787
'tabs.closeConfirmTitle': '세션 실행 중',
37783788
'tabs.closeConfirmMessage': '이 세션은 아직 실행 중입니다. 어떻게 하시겠습니까?',
37793789
'tabs.closeConfirmKeep': '계속 실행',

‎desktop/src/i18n/locales/zh-TW.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3771,6 +3771,16 @@ export const zh: Record<TranslationKey, string> = {
37713771
'tabs.closeLeft': '關閉左側',
37723772
'tabs.closeRight': '關閉右側',
37733773
'tabs.closeAll': '關閉所有',
3774+
'tabs.reopenClosed': '重新開啟已關閉的分頁',
3775+
'settings.tabs.title': '分頁',
3776+
'settings.tabs.description': '控制頂部的工作階段分頁。關閉分頁不會刪除工作階段,可以在左側清單或右鍵分頁選「重新開啟已關閉的分頁」找回。',
3777+
'settings.tabs.limit': '最多保留的工作階段分頁',
3778+
'settings.tabs.limitHint': '超出時自動關閉最久沒看過的工作階段分頁。正在執行、等你處理、有未送出內容的不會被關閉。',
3779+
'settings.tabs.limitUnlimited': '不限',
3780+
'settings.tabs.limitCount': '{count} 個',
3781+
'settings.tabs.restoreOnLaunch': '啟動時恢復上次開啟的分頁',
3782+
'settings.tabs.restoreOnLaunchHintOn': '重新開啟應用程式時,上次沒關的分頁會原樣回來。',
3783+
'settings.tabs.restoreOnLaunchHintOff': '重新開啟應用程式時分頁列為空,工作階段仍在左側清單裡。',
37743784
'tabs.closeConfirmTitle': '會話執行中',
37753785
'tabs.closeConfirmMessage': '此會話仍在執行,你想要怎麼做?',
37763786
'tabs.closeConfirmKeep': '保持執行',

‎desktop/src/i18n/locales/zh.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3770,6 +3770,16 @@ export const zh: Record<TranslationKey, string> = {
37703770
'tabs.closeLeft': '关闭左侧',
37713771
'tabs.closeRight': '关闭右侧',
37723772
'tabs.closeAll': '关闭所有',
3773+
'tabs.reopenClosed': '重新打开已关闭的标签',
3774+
'settings.tabs.title': '标签页',
3775+
'settings.tabs.description': '控制顶部会话标签。关闭标签不会删除会话,可以在左侧列表或右键标签选「重新打开已关闭的标签」找回。',
3776+
'settings.tabs.limit': '最多保留的会话标签',
3777+
'settings.tabs.limitHint': '超出时自动关闭最久没看过的会话标签。正在运行、等你处理、有未发送内容的不会被关闭。',
3778+
'settings.tabs.limitUnlimited': '不限',
3779+
'settings.tabs.limitCount': '{count} 个',
3780+
'settings.tabs.restoreOnLaunch': '启动时恢复上次打开的标签',
3781+
'settings.tabs.restoreOnLaunchHintOn': '重新打开应用时,上次没关的标签会原样回来。',
3782+
'settings.tabs.restoreOnLaunchHintOff': '重新打开应用时标签栏为空,会话仍在左侧列表里。',
37733783
'tabs.closeConfirmTitle': '会话运行中',
37743784
'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
37753785
'tabs.closeConfirmKeep': '保持运行',
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
DEFAULT_TAB_BEHAVIOR,
4+
TAB_BEHAVIOR_STORAGE_KEY,
5+
persistTabBehavior,
6+
readTabBehavior,
7+
} from './tabBehavior'
8+
9+
function memoryStorage(initial: Record<string, string> = {}) {
10+
const values = new Map(Object.entries(initial))
11+
return {
12+
getItem: (key: string) => values.get(key) ?? null,
13+
setItem: (key: string, value: string) => { values.set(key, value) },
14+
raw: (key: string) => values.get(key),
15+
}
16+
}
17+
18+
describe('tab behavior preference', () => {
19+
it('reads storage written before the setting existed as today\'s behavior', () => {
20+
// A pre-feature profile: open tabs saved, no tab behavior record.
21+
const storage = memoryStorage({
22+
'cc-haha-open-tabs': JSON.stringify({ openTabs: [{ sessionId: 's1', title: 'A', type: 'session' }], activeTabId: 's1' }),
23+
})
24+
expect(readTabBehavior(storage)).toEqual({ sessionTabLimit: null, restoreOnLaunch: true })
25+
expect(storage.raw(TAB_BEHAVIOR_STORAGE_KEY)).toBeUndefined()
26+
})
27+
28+
it('round-trips a saved choice', () => {
29+
const storage = memoryStorage()
30+
persistTabBehavior({ sessionTabLimit: 8, restoreOnLaunch: false }, storage)
31+
expect(JSON.parse(storage.raw(TAB_BEHAVIOR_STORAGE_KEY)!)).toEqual({ version: 1, sessionTabLimit: 8, restoreOnLaunch: false })
32+
expect(readTabBehavior(storage)).toEqual({ sessionTabLimit: 8, restoreOnLaunch: false })
33+
})
34+
35+
it.each([
36+
['unparseable JSON', '{nope'],
37+
['an array', '[]'],
38+
['a limit that is not one of the presets', JSON.stringify({ version: 1, sessionTabLimit: 3, restoreOnLaunch: 'no' })],
39+
])('falls back to the default for %s', (_label, raw) => {
40+
expect(readTabBehavior(memoryStorage({ [TAB_BEHAVIOR_STORAGE_KEY]: raw }))).toEqual(DEFAULT_TAB_BEHAVIOR)
41+
})
42+
43+
it('reads a record from a newer build as the default without rewriting it', () => {
44+
const raw = JSON.stringify({ version: 2, sessionTabLimit: 5, restoreOnLaunch: false, groups: true })
45+
const storage = memoryStorage({ [TAB_BEHAVIOR_STORAGE_KEY]: raw })
46+
expect(readTabBehavior(storage)).toEqual(DEFAULT_TAB_BEHAVIOR)
47+
expect(storage.raw(TAB_BEHAVIOR_STORAGE_KEY)).toBe(raw)
48+
})
49+
})

0 commit comments

Comments
 (0)