Revert "Preserve explicit slept workspace state (#4536)"
This reverts commit 0d2abc5cb7.
This commit is contained in:
parent
7c701c9319
commit
2d42e77309
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -446,14 +446,6 @@ function mergeRemoteWorkspaceSession(
|
|||
lastVisitedAtByWorktreeId: {
|
||||
...omitTargetWorktrees(current.lastVisitedAtByWorktreeId),
|
||||
...remote.lastVisitedAtByWorktreeId
|
||||
},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {
|
||||
...omitTargetWorktrees(current.defaultTerminalTabsAppliedByWorktreeId),
|
||||
...remote.defaultTerminalTabsAppliedByWorktreeId
|
||||
},
|
||||
sleptWorktreeIds: {
|
||||
...omitTargetWorktrees(current.sleptWorktreeIds),
|
||||
...remote.sleptWorktreeIds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
|
|||
worktreesByRepo: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
sleptWorktreeIds: {}
|
||||
defaultTerminalTabsAppliedByWorktreeId: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ function createSnapshot(
|
|||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
sleptWorktreeIds: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ function runtimeScopedStateReset(): Partial<AppState> {
|
|||
sortEpoch: 0,
|
||||
everActivatedWorktreeIds: new Set<string>(),
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
sleptWorktreeIds: {},
|
||||
hasHydratedWorktreePurge: false,
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in New Issue