Revert "Preserve explicit slept workspace state (#4536)"

This reverts commit 0d2abc5cb7.
This commit is contained in:
Jinjing 2026-06-03 09:48:03 -07:00
parent 7c701c9319
commit 2d42e77309
39 changed files with 215 additions and 590 deletions

View File

@ -99,7 +99,7 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => {
).toBe(true)
})
it('treats empty optional projection records as equivalent to absent fields', () => {
it('treats empty optional projection fields as equivalent to absent fields', () => {
expect(
remoteWorkspaceSessionMatchesSnapshot(
snapshot({
@ -107,11 +107,10 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => {
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {},
activeWorktreePathsOnShutdown: [],
activeTabIdByWorktreePath: {},
remoteSessionIdsByTabId: {},
lastVisitedAtByWorktreePath: {},
defaultTerminalTabsAppliedByWorktreePath: {},
sleptWorktreePaths: {}
lastVisitedAtByWorktreePath: {}
}),
{
activeWorktreePath: null,
@ -123,26 +122,6 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => {
).toBe(true)
})
it('preserves an explicit empty shutdown list as session state', () => {
expect(
remoteWorkspaceSessionMatchesSnapshot(
snapshot({
activeWorktreePath: null,
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {},
activeWorktreePathsOnShutdown: []
}),
{
activeWorktreePath: null,
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {}
}
)
).toBe(false)
})
it('detects actual target session changes', () => {
expect(
remoteWorkspaceSessionMatchesSnapshot(

View File

@ -104,9 +104,7 @@ function normalizeOptionalStringArray(value: unknown): string[] | undefined {
return undefined
}
const normalized = value.filter((entry): entry is string => typeof entry === 'string')
// Why: [] means "known no live worktrees"; undefined means legacy/unknown
// and triggers hydration fallback from tab wake hints.
return normalized
return normalized.length > 0 ? normalized : undefined
}
function normalizeOptionalRecord<T extends Record<string, unknown>>(value: unknown): T | undefined {
@ -148,11 +146,7 @@ function normalizeRemoteSession(raw: unknown): RemoteWorkspaceSession {
),
lastVisitedAtByWorktreePath: normalizeOptionalRecord<Record<string, number>>(
input.lastVisitedAtByWorktreePath
),
defaultTerminalTabsAppliedByWorktreePath: normalizeOptionalRecord<Record<string, true>>(
input.defaultTerminalTabsAppliedByWorktreePath
),
sleptWorktreePaths: normalizeOptionalRecord<Record<string, true>>(input.sleptWorktreePaths)
)
}
}

View File

@ -17,7 +17,7 @@ import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { sortWorktreesSmart } from '@/components/sidebar/smart-sort'
import { isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees'
import { isSleptWorkspace } from '@/lib/worktree-activity-state'
import { isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees'
import StatusIndicator from '@/components/sidebar/StatusIndicator'
import { cn } from '@/lib/utils'
@ -267,7 +267,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const sleptWorktreeIds = useAppStore((s) => s.sleptWorktreeIds)
const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId)
const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null)
const openNewBrowserTabInActiveWorkspace = useAppStore(
@ -313,12 +312,22 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
if (hideDefaultBranchWorkspace && isDefaultBranchWorkspace(worktree)) {
return false
}
if (!showSleepingWorkspaces && isSleptWorkspace(worktree.id, sleptWorktreeIds)) {
if (
!showSleepingWorkspaces &&
isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree)
) {
return false
}
return true
}),
[allWorktrees, hideDefaultBranchWorkspace, sleptWorktreeIds, showSleepingWorkspaces]
[
allWorktrees,
browserTabsByWorktree,
hideDefaultBranchWorkspace,
ptyIdsByTabId,
showSleepingWorkspaces,
tabsByWorktree
]
)
// Why: empty-query rows use focus-recency (lastVisitedAtByWorktreeId) with

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
hasSleepableWorkspaceActivity,
isContextWorktreeDeletable,
shouldUseNativeContextMenu,
shouldIgnoreNestedWorktreeContextMenuScope,
@ -105,6 +106,28 @@ describe('shouldContinueDeleteSiblingPositionRestore', () => {
})
})
describe('hasSleepableWorkspaceActivity', () => {
it('treats preserved empty PTY arrays as slept, not live', () => {
expect(
hasSleepableWorkspaceActivity('wt-1', { 'wt-1': [{ id: 'tab-1' }] }, { 'tab-1': [] }, {})
).toBe(false)
})
it('detects live terminal and browser activity', () => {
expect(
hasSleepableWorkspaceActivity(
'wt-1',
{ 'wt-1': [{ id: 'tab-1' }] },
{ 'tab-1': ['pty-1'] },
{}
)
).toBe(true)
expect(hasSleepableWorkspaceActivity('wt-1', {}, {}, { 'wt-1': [{ id: 'browser-1' }] })).toBe(
true
)
})
})
describe('project removal from workspace context menus', () => {
it('routes primary workspace rows to project removal in non-repo grouped views', () => {
const gitRepo = { id: 'repo-1' }

View File

@ -36,6 +36,7 @@ import type { Repo, Worktree } from '../../../../shared/types'
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
import { runSleepWorktrees } from './sleep-worktree-flow'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor'
import { getLineageRenderInfo } from './worktree-list-groups'
import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status'
@ -93,6 +94,18 @@ function shouldSuppressContextMenuFollowUpClick(contextMenuOpenedAt: number, now
)
}
function hasSleepableWorkspaceActivity(
worktreeId: string,
tabsByWorktree: Record<string, { id: string }[]>,
ptyIdsByTabId: Record<string, string[]>,
browserTabsByWorktree: Record<string, { id: string }[]>
): boolean {
const tabs = tabsByWorktree[worktreeId] ?? []
const hasLiveTerminal = tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id))
const hasBrowser = (browserTabsByWorktree[worktreeId] ?? []).length > 0
return hasLiveTerminal || hasBrowser
}
function shouldRemoveProjectFromContextMenu(
repo: Pick<Repo, 'id'> | null | undefined,
worktree: Pick<Worktree, 'isMainWorktree'>
@ -216,11 +229,21 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const worktreeMap = useWorktreeMap()
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
const scopeRef = useRef<HTMLDivElement>(null)
const contextMenuOpenedAtRef = useRef<number | null>(null)
const activeContextWorktrees = menuOpen ? contextWorktrees : effectiveSelectedWorktrees
const isMultiContext = activeContextWorktrees.length > 1
const sleepableWorktrees = useMemo(
() =>
activeContextWorktrees.filter((item) =>
hasSleepableWorkspaceActivity(item.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree)
),
[activeContextWorktrees, browserTabsByWorktree, ptyIdsByTabId, tabsByWorktree]
)
const deletingContext = useMemo(
() => activeContextWorktrees.some((item) => deleteStateByWorktreeId[item.id]?.isDeleting),
[activeContextWorktrees, deleteStateByWorktreeId]
@ -245,8 +268,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
)
const removesProject = shouldRemoveProjectFromContextMenu(repo, worktree)
const sleepLabel =
isMultiContext && activeContextWorktrees.length > 0
? `Sleep ${activeContextWorktrees.length} Workspace${activeContextWorktrees.length === 1 ? '' : 's'}`
isMultiContext && sleepableWorktrees.length > 0
? `Sleep ${sleepableWorktrees.length} Workspace${sleepableWorktrees.length === 1 ? '' : 's'}`
: 'Sleep'
const deleteLabel =
isMultiContext && batchDeleteWorktrees.length > 0
@ -358,7 +381,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
])
const handleCloseTerminals = useCallback(() => {
const worktreeIds = activeContextWorktrees.map((item) => item.id)
const worktreeIds = sleepableWorktrees.map((item) => item.id)
setMenuOpenState(false)
// Why: Sleep can remount the sidebar when it clears the active workspace.
// Let Radix finish closing the menu first so its focus/portal teardown
@ -366,7 +389,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
window.setTimeout(() => {
void runSleepWorktrees(worktreeIds)
}, 50)
}, [activeContextWorktrees, setMenuOpenState])
}, [setMenuOpenState, sleepableWorktrees])
const handleDelete = useCallback(() => {
// Folder mode handled inline because it routes to a different modal;
@ -602,7 +625,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
<TooltipTrigger asChild>
<DropdownMenuItem
onSelect={handleCloseTerminals}
disabled={deletingContext || activeContextWorktrees.length === 0}
disabled={deletingContext || sleepableWorktrees.length === 0}
>
<Moon className="size-3.5" />
{sleepLabel}
@ -661,6 +684,7 @@ export {
CLOSE_ALL_CONTEXT_MENUS_EVENT,
WORKTREE_CONTEXT_MENU_SCOPE_ATTR,
WORKTREE_NATIVE_CONTEXT_MENU_ATTR,
hasSleepableWorkspaceActivity,
isContextWorktreeDeletable,
shouldRemoveProjectFromContextMenu,
shouldUseNativeContextMenu,

View File

@ -107,6 +107,7 @@ import {
} from './visible-worktrees'
import {
getVisibleWorktreeBrowserActivityTabs,
getVisibleWorktreeTerminalActivityTabs,
getWorktreeSectionTerminalActivityTabs
} from './visible-worktree-activity-inputs'
import { selectTerminalLayoutRootsForWorktrees } from './worktree-card-status-inputs'
@ -3483,7 +3484,6 @@ const WorktreeList = React.memo(function WorktreeList({
const sortBy = useAppStore((s) => s.sortBy)
const setSortBy = useAppStore((s) => s.setSortBy)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const sleptWorktreeIds = useAppStore((s) => s.sleptWorktreeIds)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const openModal = useAppStore((s) => s.openModal)
@ -3540,6 +3540,16 @@ const WorktreeList = React.memo(function WorktreeList({
agentTargetTerminalLayoutsByTabId
])
// Read tabsByWorktree when needed for filtering or sorting
const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart'
const tabsByWorktree = useAppStore((s) =>
needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null
)
const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null))
const browserTabsByWorktree = useAppStore((s) =>
!showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null
)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
// PR cache is needed for PR-status grouping and when the PR card property
@ -3807,7 +3817,9 @@ const WorktreeList = React.memo(function WorktreeList({
const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, {
filterRepoIds,
showSleepingWorkspaces,
sleptWorktreeIds,
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree,
hideDefaultBranchWorkspace,
repoMap,
worktreeLineageById
@ -3829,7 +3841,9 @@ const WorktreeList = React.memo(function WorktreeList({
showSleepingWorkspaces,
hideDefaultBranchWorkspace,
repoMap,
sleptWorktreeIds,
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree,
sortedIds,
worktreeMap,
worktreeLineageById,

View File

@ -6,16 +6,12 @@ const mocks = vi.hoisted(() => {
setActiveWorktree: vi.fn((worktreeId: string | null) => {
state.activeWorktreeId = worktreeId
}),
markWorktreeSlept: vi.fn((worktreeId: string) => {
state.sleptWorktreeIds[worktreeId] = true
}),
shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined),
shutdownWorktreeTerminals: vi.fn(async (worktreeId: string) => {
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
state.ptyIdsByTabId[tab.id] = []
}
}),
sleptWorktreeIds: {} as Record<string, true>,
tabsByWorktree: {} as Record<string, { id: string }[]>,
ptyIdsByTabId: {} as Record<string, string[]>,
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
@ -79,14 +75,12 @@ describe('sleep flow vs queued slept-workspace activation', () => {
mocks.toastError.mockClear()
mocks.state.activeWorktreeId = 'wt-parent'
mocks.state.setActiveWorktree.mockClear()
mocks.state.markWorktreeSlept.mockClear()
mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined)
mocks.state.shutdownWorktreeTerminals.mockClear().mockImplementation(async (worktreeId) => {
for (const tab of mocks.state.tabsByWorktree[worktreeId] ?? []) {
mocks.state.ptyIdsByTabId[tab.id] = []
}
})
mocks.state.sleptWorktreeIds = {}
mocks.state.tabsByWorktree = {
'wt-parent': [{ id: 'tab-parent' }],
'wt-child-1': [{ id: 'tab-child-1' }],

View File

@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const state = {
activeWorktreeId: null as string | null,
markWorktreeSlept: vi.fn(),
setActiveWorktree: vi.fn(),
shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined),
shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined),
@ -45,7 +44,6 @@ import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow'
describe('runSleepWorktree', () => {
beforeEach(() => {
mocks.state.setActiveWorktree.mockClear()
mocks.state.markWorktreeSlept.mockClear()
mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined)
mocks.state.shutdownWorktreeTerminals.mockClear().mockResolvedValue(undefined)
mocks.state.suppressPtyExit.mockClear()
@ -72,7 +70,6 @@ describe('runSleepWorktree', () => {
expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', {
keepIdentifiers: true
})
expect(mocks.state.markWorktreeSlept).toHaveBeenCalledWith('wt-1')
const browsersCallOrder = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0]
const terminalsCallOrder = mocks.state.shutdownWorktreeTerminals.mock.invocationCallOrder[0]
expect(browsersCallOrder).toBeLessThan(terminalsCallOrder)
@ -132,7 +129,6 @@ describe('runSleepWorktree', () => {
await runSleepWorktree('wt-1')
expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled()
expect(mocks.state.markWorktreeSlept).not.toHaveBeenCalled()
expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1')
expect(mocks.toastError).toHaveBeenCalledWith(
'Failed to sleep workspace',
@ -153,12 +149,10 @@ describe('runSleepWorktree', () => {
expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalledWith('wt-1', {
keepIdentifiers: true
})
expect(mocks.state.markWorktreeSlept).not.toHaveBeenCalledWith('wt-1')
expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-2')
expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-2', {
keepIdentifiers: true
})
expect(mocks.state.markWorktreeSlept).toHaveBeenCalledWith('wt-2')
expect(mocks.toastError).toHaveBeenCalledWith(
'Failed to sleep some workspaces',
expect.objectContaining({ description: 'first failed' })

View File

@ -91,7 +91,6 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise
const {
activeWorktreeId,
setActiveWorktree,
markWorktreeSlept,
shutdownWorktreeBrowsers,
shutdownWorktreeTerminals
} = useAppStore.getState()
@ -130,7 +129,6 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise
// serializer buffers into buffersByLeafId for SSH wake to reseed
// scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c.
await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true })
markWorktreeSlept(worktreeId)
} catch (err) {
errors.push(err instanceof Error ? err.message : String(err))
}

View File

@ -14,9 +14,13 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
}: UseVisibleWorkspaceKanbanWorktreeIdsParams): ReadonlySet<string> {
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const sleptWorktreeIds = useAppStore((s) => s.sleptWorktreeIds)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const tabsByWorktree = useAppStore((s) => (!showSleepingWorkspaces ? s.tabsByWorktree : null))
const ptyIdsByTabId = useAppStore((s) => (!showSleepingWorkspaces ? s.ptyIdsByTabId : null))
const browserTabsByWorktree = useAppStore((s) =>
!showSleepingWorkspaces ? s.browserTabsByWorktree : null
)
return useMemo(() => {
// Why: the board has its own status ordering, but visibility must match
@ -26,7 +30,9 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
computeVisibleWorktreeIds(worktreesByRepo, sortedIds, {
filterRepoIds,
showSleepingWorkspaces,
sleptWorktreeIds,
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree,
hideDefaultBranchWorkspace,
repoMap,
// Why: the board has no nested lineage presentation. Ancestor injection
@ -36,11 +42,13 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
)
}, [
allWorktrees,
browserTabsByWorktree,
filterRepoIds,
hideDefaultBranchWorkspace,
ptyIdsByTabId,
repoMap,
showSleepingWorkspaces,
sleptWorktreeIds,
tabsByWorktree,
worktreesByRepo
])
}

View File

@ -6,7 +6,20 @@ import {
isDefaultBranchWorkspace,
sidebarHasActiveFilters
} from './visible-worktrees'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
import type { Repo, TerminalTab, Worktree, WorktreeLineage } from '../../../../shared/types'
function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab {
return {
id,
ptyId,
worktreeId,
title: id,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function makeWorktree(id: string, repoId = 'repo1'): Worktree & { instanceId: string } {
return {
@ -63,7 +76,9 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions
return {
filterRepoIds: [],
showSleepingWorkspaces: true,
sleptWorktreeIds: {},
tabsByWorktree: {},
ptyIdsByTabId: {},
browserTabsByWorktree: {},
hideDefaultBranchWorkspace: false,
repoMap,
worktreeLineageById: {},
@ -83,14 +98,15 @@ function filterState(overrides: Partial<FilterState> = {}): FilterState {
}
describe('computeVisibleWorktreeIds', () => {
it('keeps unslept worktrees visible when sleeping workspaces are hidden', () => {
const wt = makeWorktree('wt-unslept')
it('keeps browser-tab worktrees visible when sleeping workspaces are hidden', () => {
const wt = makeWorktree('wt-browser')
const result = computeVisibleWorktreeIds(
{ repo1: [wt] },
[wt.id],
visibleOptions({
showSleepingWorkspaces: false
showSleepingWorkspaces: false,
browserTabsByWorktree: { [wt.id]: [{ id: 'browser-1' }] }
})
)
@ -104,29 +120,14 @@ describe('computeVisibleWorktreeIds', () => {
{ repo1: [wt] },
[wt.id],
visibleOptions({
showSleepingWorkspaces: false,
sleptWorktreeIds: { [wt.id]: true }
showSleepingWorkspaces: false
})
)
expect(result).toEqual([])
})
it('does not hide unslept inactive worktrees when sleeping workspaces are hidden', () => {
const wt = makeWorktree('wt-unslept')
const result = computeVisibleWorktreeIds(
{ repo1: [wt] },
[wt.id],
visibleOptions({
showSleepingWorkspaces: false
})
)
expect(result).toEqual([wt.id])
})
it('hides explicitly slept inactive worktrees when sleeping workspaces are hidden', () => {
it('does not treat slept wake-hint tabs as live surfaces', () => {
const wt = makeWorktree('wt-slept')
const result = computeVisibleWorktreeIds(
@ -134,13 +135,47 @@ describe('computeVisibleWorktreeIds', () => {
[wt.id],
visibleOptions({
showSleepingWorkspaces: false,
sleptWorktreeIds: { [wt.id]: true }
tabsByWorktree: { [wt.id]: [makeTab('tab-slept', wt.id, 'wake-hint-session')] },
// Sleep preserves tab.ptyId as the wake hint but clears live PTY ids.
ptyIdsByTabId: { 'tab-slept': [] }
})
)
expect(result).toEqual([])
})
it('hides paired web host terminal mirrors while their stream handle is pending', () => {
const wt = makeWorktree('wt-web-pending')
const result = computeVisibleWorktreeIds(
{ repo1: [wt] },
[wt.id],
visibleOptions({
showSleepingWorkspaces: false,
tabsByWorktree: { [wt.id]: [makeTab('web-terminal-host-tab-1', wt.id, null)] },
ptyIdsByTabId: {}
})
)
expect(result).toEqual([])
})
it('keeps paired web host terminal mirrors visible after their stream handle is ready', () => {
const wt = makeWorktree('wt-web-ready')
const result = computeVisibleWorktreeIds(
{ repo1: [wt] },
[wt.id],
visibleOptions({
showSleepingWorkspaces: false,
tabsByWorktree: { [wt.id]: [makeTab('web-terminal-host-tab-1', wt.id, null)] },
ptyIdsByTabId: { 'web-terminal-host-tab-1': ['pty-web-ready'] }
})
)
expect(result).toEqual([wt.id])
})
it('hides branch-backed main worktrees when default branch workspaces are hidden', () => {
const main = makeWorktree('main')
const feature = makeWorktree('feature')
@ -188,19 +223,21 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([feature1.id, feature2.id])
})
it('composes with sleeping visibility: hidden mains stay hidden while unslept features remain', () => {
it('composes with sleeping visibility: hidden mains stay hidden while live features remain', () => {
const main = makeWorktree('main')
main.isMainWorktree = true
const feature = makeWorktree('feature')
// Why: verifies filter ordering — the default-branch hide runs before
// sleeping visibility, so the hidden main does not slip back in while the
// feature survives because it was not explicitly slept.
// feature survives because it has a live PTY.
const result = computeVisibleWorktreeIds(
{ repo1: [main, feature] },
[main.id, feature.id],
visibleOptions({
showSleepingWorkspaces: false,
tabsByWorktree: { [feature.id]: [makeTab('t1', feature.id, 'p1')] },
ptyIdsByTabId: { t1: ['p1'] },
hideDefaultBranchWorkspace: true
})
)
@ -243,7 +280,8 @@ describe('computeVisibleWorktreeIds', () => {
[child.id, parent.id],
visibleOptions({
showSleepingWorkspaces: false,
sleptWorktreeIds: { [parent.id]: true },
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
ptyIdsByTabId: { 't-child': ['p-child'] },
worktreeLineageById: { [child.id]: lineage }
})
)
@ -263,7 +301,8 @@ describe('computeVisibleWorktreeIds', () => {
[child.id, parent.id],
visibleOptions({
showSleepingWorkspaces: false,
sleptWorktreeIds: { [parent.id]: true },
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
ptyIdsByTabId: { 't-child': ['p-child'] },
worktreeLineageById: { [child.id]: lineage }
})
)
@ -282,7 +321,8 @@ describe('computeVisibleWorktreeIds', () => {
[child.id, parent.id],
visibleOptions({
showSleepingWorkspaces: false,
sleptWorktreeIds: { [parent.id]: true },
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
ptyIdsByTabId: { 't-child': ['p-child'] },
worktreeLineageById: { [child.id]: lineage }
})
)

View File

@ -1,6 +1,6 @@
import type { Worktree, Repo, WorktreeLineage } from '../../../../shared/types'
import type { Worktree, Repo, TerminalTab, WorktreeLineage } from '../../../../shared/types'
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
import { isSleptWorkspace } from '@/lib/worktree-activity-state'
import { isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { useAppStore } from '@/store'
import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
@ -84,7 +84,9 @@ export function computeVisibleWorktreeIds(
opts: {
filterRepoIds: string[]
showSleepingWorkspaces: boolean
sleptWorktreeIds: Record<string, true>
tabsByWorktree: Record<string, Pick<TerminalTab, 'id'>[]> | null
ptyIdsByTabId: Record<string, string[]> | null
browserTabsByWorktree?: Record<string, { id: string }[]> | null
// Why required: every caller (WorktreeList, getVisibleWorktreeIds
// fallback, tests) reads the flag from the UI store. Making the field
// required prevents a future caller from silently dropping the filter by
@ -114,7 +116,15 @@ export function computeVisibleWorktreeIds(
}
if (!opts.showSleepingWorkspaces) {
all = all.filter((w) => !isSleptWorkspace(w.id, opts.sleptWorktreeIds))
all = all.filter(
(w) =>
!isInactiveWorkspace(
w.id,
opts.tabsByWorktree,
opts.ptyIdsByTabId,
opts.browserTabsByWorktree
)
)
}
// Apply cached sort order. Items not yet in the cache (e.g. brand-new
@ -243,7 +253,9 @@ export function getVisibleWorktreeIds(): string[] {
return computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, {
filterRepoIds: state.filterRepoIds,
showSleepingWorkspaces: state.showSleepingWorkspaces,
sleptWorktreeIds: state.sleptWorktreeIds,
tabsByWorktree: state.tabsByWorktree,
ptyIdsByTabId: state.ptyIdsByTabId,
browserTabsByWorktree: state.browserTabsByWorktree,
hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
repoMap,
worktreeLineageById: state.worktreeLineageById

View File

@ -220,7 +220,6 @@ describe('useIpcEvents browser tab create routing', () => {
setUpdateStatus: vi.fn(),
fetchRepos: vi.fn(),
fetchWorktrees: vi.fn(),
fetchWorktreeLineage: vi.fn(),
setActiveView: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
@ -689,7 +688,6 @@ describe('useIpcEvents updater integration', () => {
setUpdateStatus: vi.fn(),
fetchRepos: vi.fn(),
fetchWorktrees: vi.fn(),
fetchWorktreeLineage: vi.fn(),
setActiveView: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
@ -2552,7 +2550,6 @@ describe('useIpcEvents agent status snapshot integration', () => {
setUpdateStatus: vi.fn(),
fetchRepos: vi.fn(),
fetchWorktrees: vi.fn(),
fetchWorktreeLineage: vi.fn(),
setActiveView: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
@ -3960,145 +3957,6 @@ describe('useIpcEvents agent status snapshot integration', () => {
expect(hydrateBrowserSession).not.toHaveBeenCalled()
})
it('merges remote slept and default-terminal markers for the changed target only', async () => {
const hydrateWorkspaceSession = vi.fn()
const onChangedListenerRef: {
current:
| ((event: {
targetId: string
sourceClientId?: string
snapshot: Record<string, unknown>
}) => void)
| null
} = { current: null }
const localWorktreeId = 'repo-local::/local'
const remoteWorktreeId = 'repo-remote::/repo'
const setRemoteWorkspaceSyncStatus = vi.fn()
const storeState: StoreLike = buildStoreState({
workspaceSessionReady: true,
activeRepoId: 'repo-local',
activeWorktreeId: localWorktreeId,
activeTabId: 'tab-local',
repos: [
{ id: 'repo-local', connectionId: null },
{ id: 'repo-remote', connectionId: 'conn-1' }
],
worktreesByRepo: {
'repo-local': [{ id: localWorktreeId, repoId: 'repo-local' }],
'repo-remote': [{ id: remoteWorktreeId, repoId: 'repo-remote' }]
},
tabsByWorktree: {
[localWorktreeId]: [
{ id: 'tab-local', ptyId: null, worktreeId: localWorktreeId, title: 'Local' }
]
},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {},
activeTabIdByWorktree: { [localWorktreeId]: 'tab-local' },
openFiles: [],
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
activeBrowserTabIdByWorktree: {},
browserUrlHistory: [],
unifiedTabsByWorktree: {},
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
sshConnectionStates: new Map(),
lastKnownRelayPtyIdByTabId: {},
lastVisitedAtByWorktreeId: {},
defaultTerminalTabsAppliedByWorktreeId: { [localWorktreeId]: true },
sleptWorktreeIds: { [localWorktreeId]: true },
hydrateWorkspaceSession,
hydrateTabsSession: vi.fn(),
hydrateEditorSession: vi.fn(),
hydrateBrowserSession: vi.fn(),
markRemoteWorkspaceHydrated: vi.fn(),
setRemoteWorkspaceSyncStatus,
reconnectPersistedTerminals: vi.fn(() => Promise.resolve())
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: () => () => {},
remoteWorkspace: {
clientId: () => Promise.resolve('client-self'),
onChanged: (cb: typeof onChangedListenerRef.current) => {
onChangedListenerRef.current = cb
return () => {}
}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
onChangedListenerRef.current?.({
targetId: 'conn-1',
sourceClientId: 'client-other',
snapshot: {
revision: 7,
updatedAt: Date.now(),
session: {
activeWorktreePath: '/repo',
activeTabId: 'tab-remote',
tabsByWorktreePath: {
'/repo': [
{
id: 'tab-remote',
ptyId: null,
worktreePath: '/repo',
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {},
defaultTerminalTabsAppliedByWorktreePath: { '/repo': true },
sleptWorktreePaths: { '/repo': true }
}
}
})
for (
let attempt = 0;
attempt < 10 && hydrateWorkspaceSession.mock.calls.length === 0;
attempt += 1
) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
expect(hydrateWorkspaceSession).toHaveBeenCalledTimes(1)
expect(hydrateWorkspaceSession).toHaveBeenCalledWith(
expect.objectContaining({
defaultTerminalTabsAppliedByWorktreeId: {
[localWorktreeId]: true,
[remoteWorktreeId]: true
},
sleptWorktreeIds: {
[localWorktreeId]: true,
[remoteWorktreeId]: true
}
})
)
})
it('silently discards snapshot entries whose tabs are still unknown', async () => {
const setAgentStatus = vi.fn()
const getSnapshot = vi.fn(() =>

View File

@ -446,14 +446,6 @@ function mergeRemoteWorkspaceSession(
lastVisitedAtByWorktreeId: {
...omitTargetWorktrees(current.lastVisitedAtByWorktreeId),
...remote.lastVisitedAtByWorktreeId
},
defaultTerminalTabsAppliedByWorktreeId: {
...omitTargetWorktrees(current.defaultTerminalTabsAppliedByWorktreeId),
...remote.defaultTerminalTabsAppliedByWorktreeId
},
sleptWorktreeIds: {
...omitTargetWorktrees(current.sleptWorktreeIds),
...remote.sleptWorktreeIds
}
}
}

View File

@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const state = {
sleptWorktreeIds: {} as Record<string, true>,
tabsByWorktree: {} as Record<string, { id: string }[]>,
ptyIdsByTabId: {} as Record<string, string[]>,
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
@ -63,7 +62,6 @@ describe('sidebar worktree activation', () => {
mocks.scheduleAfterInputQuiet.mockClear()
mocks.pendingCallbacks.length = 0
mocks.pendingCancels.length = 0
mocks.state.sleptWorktreeIds = {}
mocks.state.tabsByWorktree = {}
mocks.state.ptyIdsByTabId = {}
mocks.state.browserTabsByWorktree = {}
@ -71,7 +69,8 @@ describe('sidebar worktree activation', () => {
})
it('cancels a queued slept-workspace activation', () => {
mocks.state.sleptWorktreeIds = { 'wt-parent': true }
mocks.state.tabsByWorktree = { 'wt-parent': [{ id: 'tab-1' }] }
mocks.state.ptyIdsByTabId = { 'tab-1': [] }
activateWorktreeFromSidebar('wt-parent')
cancelPendingSidebarWorktreeActivation()
@ -81,13 +80,13 @@ describe('sidebar worktree activation', () => {
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
})
it('does not defer an unslept workspace even when it has no live PTY', () => {
mocks.state.tabsByWorktree = { 'wt-unslept': [{ id: 'tab-1' }] }
mocks.state.ptyIdsByTabId = { 'tab-1': [] }
it('does not defer a workspace with a live PTY', () => {
mocks.state.tabsByWorktree = { 'wt-live': [{ id: 'tab-1' }] }
mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-1'] }
activateWorktreeFromSidebar('wt-unslept')
activateWorktreeFromSidebar('wt-live')
expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled()
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-unslept')
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-live')
})
})

View File

@ -1,5 +1,6 @@
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
const SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS = 450
@ -17,7 +18,17 @@ export function cancelPendingSidebarWorktreeActivation(): void {
function shouldDeferSidebarWorktreeActivation(worktreeId: string): boolean {
const state = useAppStore.getState()
return Boolean(state.sleptWorktreeIds[worktreeId])
const tabs = state.tabsByWorktree[worktreeId] ?? []
if (tabs.length === 0) {
return false
}
if ((state.browserTabsByWorktree[worktreeId] ?? []).length > 0) {
return false
}
if (state.openFiles.some((file) => file.worktreeId === worktreeId)) {
return false
}
return tabs.every((tab) => !tabHasLivePty(state.ptyIdsByTabId, tab.id))
}
export function activateWorktreeFromSidebar(worktreeId: string): void {

View File

@ -29,8 +29,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
worktreesByRepo: {},
lastKnownRelayPtyIdByTabId: {},
lastVisitedAtByWorktreeId: {},
defaultTerminalTabsAppliedByWorktreeId: {},
sleptWorktreeIds: {}
defaultTerminalTabsAppliedByWorktreeId: {}
}
}

View File

@ -30,7 +30,6 @@ function createSnapshot(
lastKnownRelayPtyIdByTabId: {},
lastVisitedAtByWorktreeId: {},
defaultTerminalTabsAppliedByWorktreeId: {},
sleptWorktreeIds: {},
...overrides
}
}

View File

@ -8,7 +8,6 @@ import {
buildPersistedBrowserPagesByWorkspace,
buildPersistedBrowserTabsByWorktree,
buildSanitizedTabsByWorktree,
buildSleptWorktreeIds,
buildTerminalSessionData,
type WorkspaceSessionSnapshot
} from './workspace-session'
@ -132,9 +131,6 @@ export function buildWorkspaceSessionPatch(
? snapshot.defaultTerminalTabsAppliedByWorktreeId
: undefined
}
if (changed.has('sleptWorktreeIds')) {
patch.sleptWorktreeIds = buildSleptWorktreeIds(snapshot)
}
return patch
}

View File

@ -29,8 +29,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
worktreesByRepo: true,
lastKnownRelayPtyIdByTabId: true,
lastVisitedAtByWorktreeId: true,
defaultTerminalTabsAppliedByWorktreeId: true,
sleptWorktreeIds: true
defaultTerminalTabsAppliedByWorktreeId: true
}
it('contains every key of WorkspaceSessionSnapshot', () => {

View File

@ -54,8 +54,7 @@ export type WorkspaceSessionSnapshot = Pick<
| 'lastKnownRelayPtyIdByTabId'
| 'lastVisitedAtByWorktreeId'
| 'defaultTerminalTabsAppliedByWorktreeId'
> &
Partial<Pick<AppState, 'sleptWorktreeIds'>>
>
// Why: the App-level Zustand subscriber that debounces session writes uses
// this list as a shallow-equality gate so it only resets the timer when a
@ -88,8 +87,7 @@ export const SESSION_RELEVANT_FIELDS = [
'worktreesByRepo',
'lastKnownRelayPtyIdByTabId',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId',
'sleptWorktreeIds'
'defaultTerminalTabsAppliedByWorktreeId'
] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[]
type _MissingSessionField = Exclude<
@ -318,14 +316,6 @@ export function buildLastVisitedAtByWorktreeId(
: undefined
}
export function buildSleptWorktreeIds(
snapshot: WorkspaceSessionSnapshot
): WorkspaceSessionState['sleptWorktreeIds'] {
return snapshot.sleptWorktreeIds && Object.keys(snapshot.sleptWorktreeIds).length > 0
? snapshot.sleptWorktreeIds
: undefined
}
export function buildWorkspaceSessionPayload(
snapshot: WorkspaceSessionSnapshot
): WorkspaceSessionState {
@ -372,8 +362,7 @@ export function buildWorkspaceSessionPayload(
snapshot.defaultTerminalTabsAppliedByWorktreeId &&
Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId).length > 0
? snapshot.defaultTerminalTabsAppliedByWorktreeId
: undefined,
sleptWorktreeIds: buildSleptWorktreeIds(snapshot)
: undefined
}
return pruneLocalTerminalScrollbackBuffers(payload, snapshot.repos)

View File

@ -7,14 +7,6 @@ type BrowserLikeTab = { id: string }
type TabsByWorktree = Record<string, readonly TerminalLikeTab[]>
type PtyIdsByTabId = Record<string, string[]>
type BrowserTabsByWorktree = Record<string, readonly BrowserLikeTab[]>
type SleptWorktreeIds = Record<string, true>
export function isSleptWorkspace(
worktreeId: string,
sleptWorktreeIds: SleptWorktreeIds | null | undefined
): boolean {
return Boolean(sleptWorktreeIds?.[worktreeId])
}
export function hasActiveWorkspaceActivity(
worktreeId: string,

View File

@ -41,7 +41,6 @@ function createTestStore() {
unifiedTabsByWorktree: {},
tabBarOrderByWorktree: {},
tabsByWorktree: {},
sleptWorktreeIds: {},
openFiles: [],
activeTabType: 'terminal',
activeTabTypeByWorktree: {},
@ -174,15 +173,6 @@ describe('createBrowserSlice annotations', () => {
expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull()
})
it('clears an explicit slept marker when opening a browser tab', () => {
const store = createTestStore()
store.setState({ sleptWorktreeIds: { 'wt-1': true } })
store.getState().createBrowserTab('wt-1', 'https://example.com')
expect(store.getState().sleptWorktreeIds['wt-1']).toBeUndefined()
})
it('preserves browser map references when a page-state update is unchanged', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {

View File

@ -438,13 +438,6 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
set((s) => {
const existingTabs = s.browserTabsByWorktree[worktreeId] ?? []
const nextSleptWorktreeIds = s.sleptWorktreeIds[worktreeId]
? (() => {
const next = { ...s.sleptWorktreeIds }
delete next[worktreeId]
return next
})()
: s.sleptWorktreeIds
const nextTabBarOrder = (() => {
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
@ -509,10 +502,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
[workspaceId]: true,
[page.id]: true
}
: s.pendingAddressBarFocusByTabId,
...(nextSleptWorktreeIds !== s.sleptWorktreeIds
? { sleptWorktreeIds: nextSleptWorktreeIds }
: {})
: s.pendingAddressBarFocusByTabId
}
})

View File

@ -601,7 +601,6 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Drop this repo's timestamps explicitly so they cannot survive prune
// forever after the repo is removed.
let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId
let nextSleptWorktreeIds = s.sleptWorktreeIds
for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) {
if (getRepoIdFromWorktreeId(id) === projectId) {
if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) {
@ -610,14 +609,6 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
delete nextLastVisitedAtByWorktreeId[id]
}
}
for (const id of Object.keys(s.sleptWorktreeIds)) {
if (getRepoIdFromWorktreeId(id) === projectId) {
if (nextSleptWorktreeIds === s.sleptWorktreeIds) {
nextSleptWorktreeIds = { ...s.sleptWorktreeIds }
}
delete nextSleptWorktreeIds[id]
}
}
const nextRepos = s.repos.filter((r) => r.id !== projectId)
return {
repos: nextRepos,
@ -637,7 +628,6 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
activeFileId: activeFileCleared ? null : s.activeFileId,
activeTabType: activeFileCleared ? 'terminal' : s.activeTabType,
lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId,
sleptWorktreeIds: nextSleptWorktreeIds,
sortEpoch: s.sortEpoch + 1,
// Why: removing the last repo while in settings leaves activeView as
// 'settings', which renders an empty settings pane instead of Landing.

View File

@ -52,7 +52,6 @@ function runtimeScopedStateReset(): Partial<AppState> {
sortEpoch: 0,
everActivatedWorktreeIds: new Set<string>(),
lastVisitedAtByWorktreeId: {},
sleptWorktreeIds: {},
hasHydratedWorktreePurge: false,
unifiedTabsByWorktree: {},
groupsByWorktree: {},

View File

@ -205,23 +205,6 @@ describe('TabsSlice', () => {
}
})
})
it('clears an explicit slept marker when opening a terminal tab', () => {
store.getState().markWorktreeSlept(WT)
store.getState().createTab(WT)
expect(store.getState().sleptWorktreeIds[WT]).toBeUndefined()
})
it('clears an explicit slept marker when a terminal PTY becomes live', () => {
const tab = store.getState().createTab(WT)
store.getState().markWorktreeSlept(WT)
store.getState().updateTabPtyId(tab.id, 'pty-live')
expect(store.getState().sleptWorktreeIds[WT]).toBeUndefined()
})
})
// ─── closeUnifiedTab ────────────────────────────────────────────────

View File

@ -227,104 +227,6 @@ describe('hydrateWorkspaceSession', () => {
})
})
it('hydrates only explicit slept markers that are not awaiting reconnect', () => {
const store = createTestStore()
const sleptWorktreeId = 'repo1::/slept'
const reconnectWorktreeId = 'repo1::/reconnect'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: sleptWorktreeId, repoId: 'repo1', path: '/slept' }),
makeWorktree({ id: reconnectWorktreeId, repoId: 'repo1', path: '/reconnect' })
]
}
})
const session: WorkspaceSessionState = {
activeRepoId: 'repo1',
activeWorktreeId: sleptWorktreeId,
activeTabId: null,
activeWorktreeIdsOnShutdown: [reconnectWorktreeId],
terminalLayoutsByTabId: {},
tabsByWorktree: {
[reconnectWorktreeId]: [
makeTab({ id: 'tab-reconnect', worktreeId: reconnectWorktreeId, ptyId: 'pty-1' })
]
},
sleptWorktreeIds: {
[sleptWorktreeId]: true,
[reconnectWorktreeId]: true,
'repo1::/missing': true
}
}
store.getState().hydrateWorkspaceSession(session)
expect(store.getState().sleptWorktreeIds).toEqual({ [sleptWorktreeId]: true })
expect(store.getState().pendingReconnectWorktreeIds).toEqual([reconnectWorktreeId])
})
it('does not reconnect explicitly slept wake-hint tabs when shutdown list is missing', () => {
const store = createTestStore()
const sleptWorktreeId = 'repo1::/slept'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: sleptWorktreeId, repoId: 'repo1', path: '/slept' })]
}
})
const session: WorkspaceSessionState = {
activeRepoId: 'repo1',
activeWorktreeId: null,
activeTabId: null,
terminalLayoutsByTabId: {},
tabsByWorktree: {
[sleptWorktreeId]: [
makeTab({ id: 'tab-slept', worktreeId: sleptWorktreeId, ptyId: 'wake-hint' })
]
},
sleptWorktreeIds: {
[sleptWorktreeId]: true
}
}
store.getState().hydrateWorkspaceSession(session)
expect(store.getState().sleptWorktreeIds).toEqual({ [sleptWorktreeId]: true })
expect(store.getState().pendingReconnectWorktreeIds).toEqual([])
})
it('does not reconnect explicitly slept wake-hint tabs when shutdown list is empty', () => {
const store = createTestStore()
const sleptWorktreeId = 'repo1::/slept'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: sleptWorktreeId, repoId: 'repo1', path: '/slept' })]
}
})
const session: WorkspaceSessionState = {
activeRepoId: 'repo1',
activeWorktreeId: null,
activeTabId: null,
activeWorktreeIdsOnShutdown: [],
terminalLayoutsByTabId: {},
tabsByWorktree: {
[sleptWorktreeId]: [
makeTab({ id: 'tab-slept', worktreeId: sleptWorktreeId, ptyId: 'wake-hint' })
]
},
sleptWorktreeIds: {
[sleptWorktreeId]: true
}
}
store.getState().hydrateWorkspaceSession(session)
expect(store.getState().sleptWorktreeIds).toEqual({ [sleptWorktreeId]: true })
expect(store.getState().pendingReconnectWorktreeIds).toEqual([])
})
it('seeds worktree nav history with the restored active worktree', () => {
// Why: without seeding, the first sidebar click after startup becomes the
// only history entry, so Back stays disabled until the user clicks a

View File

@ -261,9 +261,6 @@ export type TerminalSlice = {
workspaceSessionReady: boolean
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
markDefaultTerminalTabsApplied: (worktreeId: string) => void
sleptWorktreeIds: Record<string, true>
markWorktreeSlept: (worktreeId: string) => void
clearWorktreeSlept: (worktreeId: string) => void
/** True only after hydrateWorkspaceSession ran from a real load of
* orca-data.json. Guards the debounced session writer so that a crash
* during early startup (fetchRepos / fetchAllWorktrees / session.get /
@ -448,28 +445,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
}
}),
sleptWorktreeIds: {},
markWorktreeSlept: (worktreeId) =>
set((s) => {
if (s.sleptWorktreeIds[worktreeId]) {
return {}
}
return {
sleptWorktreeIds: {
...s.sleptWorktreeIds,
[worktreeId]: true
}
}
}),
clearWorktreeSlept: (worktreeId) =>
set((s) => {
if (!s.sleptWorktreeIds[worktreeId]) {
return {}
}
const next = { ...s.sleptWorktreeIds }
delete next[worktreeId]
return { sleptWorktreeIds: next }
}),
hydrationSucceeded: false,
setHydrationSucceeded: (value) => {
set({ hydrationSucceeded: value })
@ -570,13 +545,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const existing = (s.tabsByWorktree[worktreeId] ?? []).filter(
(entry) => !orphanTerminalIds.has(entry.id)
)
const nextSleptWorktreeIds = s.sleptWorktreeIds[worktreeId]
? (() => {
const next = { ...s.sleptWorktreeIds }
delete next[worktreeId]
return next
})()
: s.sleptWorktreeIds
// Why: caller-supplied id (e.g. main pre-allocates the tabId for CLI
// background terminals so the paneKey env baked into the PTY matches
// the renderer's tab id). Fall back to minting if the id collides — a
@ -723,10 +691,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
terminalLayoutsByTabId: {
...s.terminalLayoutsByTabId,
[tab.id]: emptyLayoutSnapshot()
},
...(nextSleptWorktreeIds !== s.sleptWorktreeIds
? { sleptWorktreeIds: nextSleptWorktreeIds }
: {})
}
}
})
const shouldRecordInteraction =
@ -1384,14 +1349,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const isFirstPty = existingPtyIds.length === 0
const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId
const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn
const nextSleptWorktreeIds =
worktreeId && s.sleptWorktreeIds[worktreeId]
? (() => {
const next = { ...s.sleptWorktreeIds }
delete next[worktreeId!]
return next
})()
: s.sleptWorktreeIds
return {
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}),
ptyIdsByTabId: {
@ -1402,9 +1359,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
...s.lastKnownRelayPtyIdByTabId,
[tabId]: ptyId
},
...(nextSleptWorktreeIds !== s.sleptWorktreeIds
? { sleptWorktreeIds: nextSleptWorktreeIds }
: {}),
...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {})
}
})
@ -2018,23 +1972,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// activeWorktreeIdsOnShutdown is absent (upgrade from older build).
// The raw tabs still carry ptyId values before clearTransientTerminalState
// nulls them, so we can infer which worktrees had active terminals.
// If an explicit slept marker is present, do not let this legacy fallback
// reinterpret its preserved wake hint as an active reconnect target.
const shutdownIds =
session.activeWorktreeIdsOnShutdown ??
Object.entries(session.tabsByWorktree)
.filter(
([worktreeId, tabs]) =>
!session.sleptWorktreeIds?.[worktreeId] && tabs.some((t) => t.ptyId)
)
.filter(([, tabs]) => tabs.some((t) => t.ptyId))
.map(([wId]) => wId)
const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id))
const sleptWorktreeIds = Object.fromEntries(
Object.entries(session.sleptWorktreeIds ?? {}).filter(
([worktreeId]) =>
validWorktreeIds.has(worktreeId) && !pendingReconnectWorktreeIds.includes(worktreeId)
)
) as Record<string, true>
// Why: capture which specific tabs had live PTYs per worktree from the
// raw session data BEFORE clearTransientTerminalState nulled the ptyIds.
@ -2177,7 +2120,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {},
defaultTerminalTabsAppliedByWorktreeId:
session.defaultTerminalTabsAppliedByWorktreeId ?? {},
sleptWorktreeIds,
pendingReconnectWorktreeIds,
pendingReconnectTabByWorktree,
pendingReconnectPtyIdByTabId,

View File

@ -106,7 +106,6 @@ function createTestStore() {
shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined),
shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined),
tabsByWorktree: {},
sleptWorktreeIds: {},
tabBarOrderByWorktree: {},
pendingReconnectTabByWorktree: {},
activeTabIdByWorktree: {},
@ -2731,19 +2730,6 @@ describe('worktree unread (show-until-interact)', () => {
})
)
})
it('clears an explicit slept marker when activating the worktree', () => {
const store = createTestStore()
const worktree = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })
store.setState({
worktreesByRepo: { repo1: [worktree] },
sleptWorktreeIds: { [worktree.id]: true }
} as Partial<AppState>)
store.getState().setActiveWorktree(worktree.id)
expect(store.getState().sleptWorktreeIds[worktree.id]).toBeUndefined()
})
})
// Why: design §4.4 — the hydration-time purge must be gated behind a

View File

@ -657,7 +657,6 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree),
tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree),
pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree),
sleptWorktreeIds: omitByWorktree(s.sleptWorktreeIds),
rightSidebarTabByWorktree: omitByWorktree(s.rightSidebarTabByWorktree),
// Split-tab / unified tab state
unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree),
@ -1335,14 +1334,6 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
return next
})()
: s.lastVisitedAtByWorktreeId
const nextSleptWorktreeIds =
worktreeId in s.sleptWorktreeIds
? (() => {
const next = { ...s.sleptWorktreeIds }
delete next[worktreeId]
return next
})()
: s.sleptWorktreeIds
return {
worktreesByRepo: next,
worktreeLineageById: nextLineage,
@ -1401,7 +1392,6 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
activeTabType: removedActiveWorktree || activeFileCleared ? 'terminal' : s.activeTabType,
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId,
sleptWorktreeIds: nextSleptWorktreeIds,
sortEpoch: s.sortEpoch + 1
}
})
@ -2205,14 +2195,6 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const nextEverActivated = isFirstActivation
? new Set([...s.everActivatedWorktreeIds, worktreeId!])
: s.everActivatedWorktreeIds
const nextSleptWorktreeIds =
worktreeId in s.sleptWorktreeIds
? (() => {
const next = { ...s.sleptWorktreeIds }
delete next[worktreeId]
return next
})()
: s.sleptWorktreeIds
const nextWorktrees = shouldClearUnread
? applyWorktreeUpdates(s.worktreesByRepo, worktreeId, metaUpdates)
: s.worktreesByRepo
@ -2247,7 +2229,6 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
s.activeTabId !== activeTabId ||
nextActiveTabTypeByWorktree !== s.activeTabTypeByWorktree ||
nextEverActivated !== s.everActivatedWorktreeIds ||
nextSleptWorktreeIds !== s.sleptWorktreeIds ||
nextWorktrees !== s.worktreesByRepo ||
nextDetectedWorktrees !== s.detectedWorktreesByRepo
if (!hasStateChange) {
@ -2265,9 +2246,6 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeTabId,
everActivatedWorktreeIds: nextEverActivated,
...(nextSleptWorktreeIds !== s.sleptWorktreeIds
? { sleptWorktreeIds: nextSleptWorktreeIds }
: {}),
...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees } : {}),
...(nextDetectedWorktrees !== s.detectedWorktreesByRepo
? { detectedWorktreesByRepo: nextDetectedWorktrees }

View File

@ -32,7 +32,6 @@ describe('sanitizeWebRuntimeWorkspaceSession', () => {
}
},
activeWorktreeIdsOnShutdown: ['repo-1::/worktree'],
sleptWorktreeIds: { 'repo-1::/worktree': true },
openFilesByWorktree: {
'repo-1::/worktree': [
{
@ -147,7 +146,6 @@ describe('sanitizeWebRuntimeWorkspaceSession', () => {
})
expect(sanitized.remoteSessionIdsByTabId).toBeUndefined()
expect(sanitized.activeWorktreeIdsOnShutdown).toBeUndefined()
expect(sanitized.sleptWorktreeIds).toEqual({ 'repo-1::/worktree': true })
expect(sanitized.unifiedTabs).toBeUndefined()
expect(sanitized.tabGroups).toBeUndefined()
})

View File

@ -13,7 +13,6 @@ export function sanitizeWebRuntimeWorkspaceSession(
activeRepoId: session.activeRepoId ?? null,
activeWorktreeId: session.activeWorktreeId ?? null,
browserUrlHistory: session.browserUrlHistory ?? defaults.browserUrlHistory,
lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId,
sleptWorktreeIds: session.sleptWorktreeIds ?? defaults.sleptWorktreeIds
lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId
}
}

View File

@ -49,10 +49,6 @@ describe('remote workspace session projection', () => {
defaultTerminalTabsAppliedByWorktreeId: {
'repo-a::/srv/app': true as const,
'repo-local::/tmp/local': true as const
},
sleptWorktreeIds: {
'repo-a::/srv/app': true as const,
'repo-local::/tmp/local': true as const
}
}
@ -70,7 +66,6 @@ describe('remote workspace session projection', () => {
})
expect(projected.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'pty-1' })
expect(projected.defaultTerminalTabsAppliedByWorktreePath).toEqual({ '/srv/app': true })
expect(projected.sleptWorktreePaths).toEqual({ '/srv/app': true })
})
it('imports projected terminal state into this client repo id', () => {
@ -96,8 +91,7 @@ describe('remote workspace session projection', () => {
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
},
remoteSessionIdsByTabId: { 'tab-1': 'pty-1' },
defaultTerminalTabsAppliedByWorktreePath: { '/srv/app': true },
sleptWorktreePaths: { '/srv/app': true }
defaultTerminalTabsAppliedByWorktreePath: { '/srv/app': true }
},
{ resolveWorktreeId: (path) => (path === '/srv/app' ? 'repo-b::/srv/app' : null) }
)
@ -112,9 +106,6 @@ describe('remote workspace session projection', () => {
expect(session.defaultTerminalTabsAppliedByWorktreeId).toEqual({
'repo-b::/srv/app': true
})
expect(session.sleptWorktreeIds).toEqual({
'repo-b::/srv/app': true
})
})
it('imports active worktree metadata even when the worktree has no terminal tabs', () => {

View File

@ -90,17 +90,6 @@ export function exportRemoteWorkspaceSession(
}
}
const sleptWorktreePaths: Record<string, true> = {}
for (const worktreeId of Object.keys(session.sleptWorktreeIds ?? {})) {
if (!options.isTargetWorktree(worktreeId)) {
continue
}
const worktreePath = worktreePathFromId(worktreeId)
if (worktreePath) {
sleptWorktreePaths[worktreePath] = true
}
}
return {
activeWorktreePath,
activeTabId,
@ -123,8 +112,7 @@ export function exportRemoteWorkspaceSession(
)
: undefined,
lastVisitedAtByWorktreePath,
defaultTerminalTabsAppliedByWorktreePath,
sleptWorktreePaths
defaultTerminalTabsAppliedByWorktreePath
}
}
@ -189,14 +177,6 @@ export function importRemoteWorkspaceSession(
}
}
const sleptWorktreeIds: Record<string, true> = {}
for (const worktreePath of Object.keys(remote.sleptWorktreePaths ?? {})) {
const worktreeId = resolvePath(worktreePath)
if (worktreeId) {
sleptWorktreeIds[worktreeId] = true
}
}
return {
...session,
activeRepoId: activeWorktreeId ? (splitWorktreeId(activeWorktreeId)?.repoId ?? null) : null,
@ -220,7 +200,6 @@ export function importRemoteWorkspaceSession(
)
: undefined,
lastVisitedAtByWorktreeId,
defaultTerminalTabsAppliedByWorktreeId,
sleptWorktreeIds
defaultTerminalTabsAppliedByWorktreeId
}
}

View File

@ -14,7 +14,6 @@ export type RemoteWorkspaceSession = {
remoteSessionIdsByTabId?: Record<string, string>
lastVisitedAtByWorktreePath?: Record<string, number>
defaultTerminalTabsAppliedByWorktreePath?: Record<string, true>
sleptWorktreePaths?: Record<string, true>
}
export type RemoteWorkspaceSnapshot = {

View File

@ -719,9 +719,6 @@ export type WorkspaceSessionState = {
* considered. Persisted so closing all tabs and re-opening the workspace
* does not recreate the template. */
defaultTerminalTabsAppliedByWorktreeId?: Record<string, true>
/** Worktrees the user explicitly slept. Inactive terminal state alone does
* not imply sleep; a worktree only sleeps through the user sleep action. */
sleptWorktreeIds?: Record<string, true>
}
export type WorkspaceSessionPatch = Partial<WorkspaceSessionState>

View File

@ -242,26 +242,6 @@ describe('parseWorkspaceSession', () => {
}
})
it('accepts explicit slept worktree markers', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleptWorktreeIds: {
'repo1::/path/wt1': true
}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.sleptWorktreeIds).toEqual({
'repo1::/path/wt1': true
})
}
})
it('caps oversized browser history while parsing legacy workspace sessions', () => {
const result = parseWorkspaceSession({
activeRepoId: null,

View File

@ -254,8 +254,7 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
z.record(z.string(), z.number().finite().nonnegative())
)
.optional(),
defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional(),
sleptWorktreeIds: z.record(z.string(), z.literal(true)).optional()
defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional()
})
export type ParsedWorkspaceSession =