Show sleeping workspaces by default (#2514)

- Add a narrow SSH relay RPC for refreshing remote-tracking refs without
  reopening generic fetch execution
- Resolve SSH connection context from composite worktree IDs during startup
  before worktree discovery completes
- Make the sleeping workspace filter negative-form and reset to the new visible
  default
- Tolerate transient xterm scroll restoration failures during layout
- Restore macOS Electron framework symlinks after copying the dev app
This commit is contained in:
Jinjing 2026-05-21 02:12:22 -07:00 committed by GitHub
parent 39badc9d5a
commit ac10c4075f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 377 additions and 56 deletions

View File

@ -4,11 +4,14 @@ import {
chmodSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import net from 'node:net'
@ -136,7 +139,7 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v3'
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@ -197,6 +200,7 @@ function prepareMacDevElectronApp() {
// Why: Electron.framework uses relative symlinks for its bundle resources;
// resolving them to pnpm-store absolutes breaks Chromium's bundle lookup.
cpSync(sourceAppPath, appPath, { recursive: true, verbatimSymlinks: true })
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
setPlistValue(plistPath, 'CFBundleName', title)
@ -210,6 +214,53 @@ function prepareMacDevElectronApp() {
process.env.ELECTRON_EXEC_PATH = executablePath
}
function isSymlink(filePath) {
try {
return lstatSync(filePath).isSymbolicLink()
} catch {
return false
}
}
function ensureRelativeSymlink(linkPath, target) {
if (isSymlink(linkPath)) {
try {
if (readlinkSync(linkPath) === target) {
return
}
} catch {}
}
const targetPath = path.join(path.dirname(linkPath), target)
if (!existsSync(targetPath)) {
return
}
rmSync(linkPath, { recursive: true, force: true })
symlinkSync(target, linkPath)
}
function restoreElectronFrameworkSymlinks(appPath) {
const frameworkPath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework'
)
const versionsPath = path.join(frameworkPath, 'Versions')
if (!existsSync(path.join(versionsPath, 'A'))) {
return
}
// Why: some Electron installs have framework symlinks flattened into
// duplicate directories. Recreate the relative bundle links after copying so
// Chromium resolves resources through the canonical macOS framework layout.
ensureRelativeSymlink(path.join(versionsPath, 'Current'), 'A')
for (const entry of ['Electron Framework', 'Resources', 'Libraries', 'Helpers']) {
ensureRelativeSymlink(path.join(frameworkPath, entry), `Versions/Current/${entry}`)
}
}
function getDevUserDataPath() {
if (process.env.ORCA_DEV_USER_DATA_PATH) {
return process.env.ORCA_DEV_USER_DATA_PATH

View File

@ -450,13 +450,11 @@ async function prepareWorktreePushTargetSsh(
remoteCreated = true
}
}
await provider.exec(
[
'fetch',
remoteName,
`+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}`
],
repoPath
await provider.fetchRemoteTrackingRef(
repoPath,
remoteName,
target.branchName,
`refs/remotes/${remoteName}/${target.branchName}`
)
return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) }
}
@ -671,14 +669,11 @@ export async function createRemoteWorktree(
const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch)
if (remoteTrackingBase) {
try {
await provider.exec(
[
'fetch',
'--no-tags',
remoteTrackingBase.remote,
`+refs/heads/${remoteTrackingBase.branch}:${remoteTrackingBase.ref}`
],
repo.path
await provider.fetchRemoteTrackingRef(
repo.path,
remoteTrackingBase.remote,
remoteTrackingBase.branch,
remoteTrackingBase.ref
)
} catch {
throw new Error(

View File

@ -870,6 +870,7 @@ describe('registerWorktreeHandlers', () => {
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
@ -945,6 +946,7 @@ describe('registerWorktreeHandlers', () => {
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
@ -1030,6 +1032,7 @@ describe('registerWorktreeHandlers', () => {
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')),
addWorktree: vi.fn(),
listWorktrees: vi.fn()
}
@ -1052,6 +1055,12 @@ describe('registerWorktreeHandlers', () => {
)
expect(provider.addWorktree).not.toHaveBeenCalled()
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
})
it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => {

View File

@ -496,6 +496,22 @@ describe('SshGitProvider', () => {
})
})
it('fetchRemoteTrackingRef sends git.fetchRemoteTrackingRef request', async () => {
await provider.fetchRemoteTrackingRef(
'/home/user/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', {
worktreePath: '/home/user/repo',
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main'
})
})
it('getBranchDiff sends git.branchDiff request', async () => {
const diffs = [{ kind: 'text', originalContent: '', modifiedContent: 'new' }]
mux.request.mockResolvedValue(diffs)

View File

@ -320,6 +320,20 @@ export class SshGitProvider implements IGitProvider {
await this.mux.request('git.fetch', { worktreePath })
}
async fetchRemoteTrackingRef(
worktreePath: string,
remote: string,
branch: string,
ref: string
): Promise<void> {
await this.mux.request('git.fetchRemoteTrackingRef', {
worktreePath,
remote,
branch,
ref
})
}
async getBranchDiff(
worktreePath: string,
baseRef: string,

View File

@ -44,11 +44,16 @@ describe('client UI RPC methods', () => {
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(
makeRequest('ui.set', { showActiveOnly: true, filterRepoIds: ['repo-1'] })
makeRequest('ui.set', {
showActiveOnly: true,
hideSleepingWorkspaces: true,
filterRepoIds: ['repo-1']
})
)
expect(runtime.updateUIState).toHaveBeenCalledWith({
showActiveOnly: true,
hideSleepingWorkspaces: true,
filterRepoIds: ['repo-1']
})
expect(response).toMatchObject({ ok: true, result: { ui: updated } })

View File

@ -55,6 +55,7 @@ const UiUpdate = z
showWorkspaceLineage: z.boolean().optional(),
sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(),
showActiveOnly: z.boolean().optional(),
hideSleepingWorkspaces: z.boolean().optional(),
showSleepingWorkspaces: z.boolean().optional(),
showInactiveWorkspaces: z.boolean().optional(),
hideDefaultBranchWorkspace: z.boolean().optional(),

View File

@ -51,6 +51,7 @@ describe('GitHandler', () => {
expect(methods).toContain('git.branchCompare')
expect(methods).toContain('git.upstreamStatus')
expect(methods).toContain('git.fetch')
expect(methods).toContain('git.fetchRemoteTrackingRef')
expect(methods).toContain('git.push')
expect(methods).toContain('git.pull')
expect(methods).toContain('git.branchDiff')
@ -630,6 +631,78 @@ describe('GitHandler', () => {
}
})
it('refreshes one remote-tracking ref from a configured remote', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-'))
const producerDir = path.join(producerParent, 'repo')
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@test.com'], {
cwd: producerDir,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'Test'], {
cwd: producerDir,
stdio: 'pipe'
})
writeFileSync(path.join(producerDir, 'base.txt'), 'updated')
gitCommit(producerDir, 'remote update')
execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' })
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: producerDir,
encoding: 'utf-8'
}).trim()
await dispatcher.callRequest('git.fetchRemoteTrackingRef', {
worktreePath: tmpDir,
remote: 'origin',
branch,
ref: `refs/remotes/origin/${branch}`
})
const actual = execFileSync('git', ['rev-parse', `refs/remotes/origin/${branch}`], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
await fs.rm(producerParent, { recursive: true, force: true })
}
})
it('rejects remote-tracking refreshes that target a different ref', async () => {
gitInit(tmpDir)
execFileSync('git', ['remote', 'add', 'origin', tmpDir], { cwd: tmpDir, stdio: 'pipe' })
await expect(
dispatcher.callRequest('git.fetchRemoteTrackingRef', {
worktreePath: tmpDir,
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/other'
})
).rejects.toThrow('Remote-tracking ref does not match the requested remote and branch.')
})
it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => {
// Why: the handler's catch is narrowed to only swallow the expected
// "no upstream" signal. A non-repo path should surface its error rather

View File

@ -58,6 +58,7 @@ export class GitHandler {
this.dispatcher.onRequest('git.commitCompare', (p) => this.commitCompare(p))
this.dispatcher.onRequest('git.upstreamStatus', (p) => this.upstreamStatus(p))
this.dispatcher.onRequest('git.fetch', (p) => this.fetch(p))
this.dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => this.fetchRemoteTrackingRef(p))
this.dispatcher.onRequest('git.push', (p) => this.push(p))
this.dispatcher.onRequest('git.pull', (p) => this.pull(p))
this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p))
@ -339,6 +340,41 @@ export class GitHandler {
}
}
private async fetchRemoteTrackingRef(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
const remote = params.remote
const branch = params.branch
const ref = params.ref
if (typeof remote !== 'string' || typeof branch !== 'string' || typeof ref !== 'string') {
throw new Error('Invalid remote-tracking fetch request.')
}
if (remote.startsWith('-') || branch.startsWith('-')) {
throw new Error('Remote-tracking fetch inputs must not start with "-".')
}
if (ref !== `refs/remotes/${remote}/${branch}`) {
throw new Error('Remote-tracking ref does not match the requested remote and branch.')
}
try {
const { stdout } = await this.git(['remote'], worktreePath)
const remotes = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (!remotes.includes(remote)) {
throw new Error(`Remote "${remote}" is not configured.`)
}
await this.git(['check-ref-format', `refs/heads/${branch}`], worktreePath)
await this.git(['check-ref-format', ref], worktreePath)
await this.git(['fetch', '--no-tags', remote, `+refs/heads/${branch}:${ref}`], worktreePath)
} catch (error) {
// Why: create-worktree needs a write-capable fetch, but generic git.exec
// intentionally rejects fetch. This narrow RPC keeps the relay allowlist
// tight while preserving the same safe error normalization as git.fetch.
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
}
}
private async push(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
// Why: mirror src/main/git/remote.ts. Push to a configured upstream when

View File

@ -878,6 +878,7 @@ function App(): React.JSX.Element {
groupBy,
sortBy,
showActiveOnly: false,
hideSleepingWorkspaces: !showSleepingWorkspaces,
showSleepingWorkspaces,
hideDefaultBranchWorkspace,
filterRepoIds,

View File

@ -19,6 +19,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { searchRepos } from '@/lib/repo-search'
import { cn } from '@/lib/utils'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
type SidebarFilterProps = {
preserveWorkspaceBoardOpen?: boolean
@ -88,9 +89,10 @@ const SidebarFilter = React.memo(function SidebarFilter({
}, [repos, filterRepoIds])
const selectedCount = selectedRepoIdSet.size
const hasRepoFilter = selectedCount > 0
const hasAnyFilter = showSleepingWorkspaces || hideDefaultBranchWorkspace || hasRepoFilter
const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES
const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter
const activeFilterCount =
(showSleepingWorkspaces ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
(hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
@ -106,7 +108,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
const allSelected = canFilterRepos && selectedCount === repos.length
const clearAll = useCallback(() => {
setShowSleepingWorkspaces(false)
setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES)
setHideDefaultBranchWorkspace(false)
setFilterRepoIds([])
}, [setShowSleepingWorkspaces, setHideDefaultBranchWorkspace, setFilterRepoIds])
@ -161,9 +163,9 @@ const SidebarFilter = React.memo(function SidebarFilter({
>
<FilterToggleRow
icon={<Moon className="size-3.5" />}
label="Show sleeping"
checked={showSleepingWorkspaces}
onChange={setShowSleepingWorkspaces}
label="Hide sleeping"
checked={!showSleepingWorkspaces}
onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)}
/>
<FilterToggleRow
icon={<GitBranch className="size-3.5" />}

View File

@ -16,9 +16,9 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
</div>
<FilterToggleRow
icon={<Moon className="size-3.5" />}
label="Show sleeping"
checked={showSleepingWorkspaces}
onChange={setShowSleepingWorkspaces}
label="Hide sleeping"
checked={!showSleepingWorkspaces}
onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)}
/>
<FilterToggleRow
icon={<GitBranch className="size-3.5" />}

View File

@ -18,6 +18,7 @@ import {
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { WorktreeCardProperty } from '../../../../shared/types'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection'
import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection'
@ -99,9 +100,10 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
return count
}, [repos, filterRepoIds])
const hasRepoFilter = selectedCount > 0
const hasAnyFilter = showSleepingWorkspaces || hideDefaultBranchWorkspace || hasRepoFilter
const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES
const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter
const activeFilterCount =
(showSleepingWorkspaces ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
(hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`
const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort'
const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) =>

View File

@ -38,6 +38,12 @@ vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('./use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'idle'
}))

View File

@ -34,6 +34,7 @@ import type {
WorkspaceStatus,
WorkspaceStatusDefinition
} from '../../../../shared/types'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
import { buildWorktreeComparator } from './smart-sort'
import {
buildAttentionByWorktree,
@ -2842,7 +2843,7 @@ const WorktreeList = React.memo(function WorktreeList({
const clearFilters = useCallback(() => {
const actions = computeClearFilterActions(filterState)
if (actions.resetShowSleepingWorkspaces) {
setShowSleepingWorkspaces(false)
setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES)
}
if (actions.resetFilterRepoIds) {
setFilterRepoIds([])

View File

@ -90,7 +90,7 @@ type FilterState = Parameters<typeof sidebarHasActiveFilters>[0]
function filterState(overrides: Partial<FilterState> = {}): FilterState {
return {
showSleepingWorkspaces: false,
showSleepingWorkspaces: true,
filterRepoIds: [],
hideDefaultBranchWorkspace: false,
...overrides
@ -382,8 +382,8 @@ describe('sidebarHasActiveFilters', () => {
expect(sidebarHasActiveFilters(filterState({ hideDefaultBranchWorkspace: true }))).toBe(true)
})
it('returns true when only showSleepingWorkspaces is active', () => {
expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: true }))).toBe(true)
it('returns true when sleeping workspaces are hidden', () => {
expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true)
})
it('returns true when only filterRepoIds is non-empty', () => {
@ -416,12 +416,11 @@ describe('computeClearFilterActions', () => {
// in the common case where hide was never on.
const actions = computeClearFilterActions(
filterState({
showSleepingWorkspaces: true,
filterRepoIds: ['repo1']
})
)
expect(actions.resetHideDefaultBranchWorkspace).toBe(false)
expect(actions.resetShowSleepingWorkspaces).toBe(true)
expect(actions.resetShowSleepingWorkspaces).toBe(false)
expect(actions.resetFilterRepoIds).toBe(true)
})
@ -429,7 +428,7 @@ describe('computeClearFilterActions', () => {
expect(
computeClearFilterActions(
filterState({
showSleepingWorkspaces: true,
showSleepingWorkspaces: false,
filterRepoIds: ['repo1', 'repo2'],
hideDefaultBranchWorkspace: true
})

View File

@ -3,6 +3,7 @@ import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
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'
/**
* Whether a worktree represents the repo's default-branch row that the
@ -35,7 +36,7 @@ export type SidebarFilterState = {
*/
export function sidebarHasActiveFilters(state: SidebarFilterState): boolean {
return (
state.showSleepingWorkspaces ||
state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES ||
state.filterRepoIds.length > 0 ||
state.hideDefaultBranchWorkspace
)
@ -61,7 +62,7 @@ export type ClearFilterActions = {
*/
export function computeClearFilterActions(state: SidebarFilterState): ClearFilterActions {
return {
resetShowSleepingWorkspaces: state.showSleepingWorkspaces,
resetShowSleepingWorkspaces: state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES,
resetFilterRepoIds: state.filterRepoIds.length > 0,
resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace
}

View File

@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { Repo } from '../../../shared/types'
import { useAppStore } from '@/store'
import { getConnectionId } from './connection-context'
const initialState = useAppStore.getInitialState()
function makeRepo(overrides: Partial<Repo> & { id: string }): Repo {
return {
path: '/home/neil/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
...overrides
}
}
describe('getConnectionId', () => {
afterEach(() => {
useAppStore.setState(initialState, true)
})
it('resolves SSH targets from composite worktree IDs before worktree discovery completes', () => {
useAppStore.setState({
repos: [
makeRepo({
id: 'repo-ssh',
connectionId: 'ssh-1'
})
],
worktreesByRepo: {}
})
expect(getConnectionId('repo-ssh::/home/neil/repo-feature')).toBe('ssh-1')
})
it('returns null for known local repos without a discovered worktree', () => {
useAppStore.setState({
repos: [makeRepo({ id: 'repo-local' })],
worktreesByRepo: {}
})
expect(getConnectionId('repo-local::/Users/me/repo-feature')).toBeNull()
})
it('returns undefined when neither the worktree nor repo is known', () => {
useAppStore.setState({
repos: [],
worktreesByRepo: {}
})
expect(getConnectionId('repo-missing::/tmp/repo-feature')).toBeUndefined()
})
})

View File

@ -1,4 +1,5 @@
import { useAppStore } from '@/store'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
/**
* Resolve the SSH connectionId for a worktree. Returns null for local repos,
@ -12,9 +13,12 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
const state = useAppStore.getState()
const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)
if (!worktree) {
// Why: SSH worktrees can be restored from session IDs before relay discovery
// repopulates worktreesByRepo. The composite ID still carries the repo ID.
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = state.repos?.find((r) => r.id === repoId)
if (!repo) {
return undefined
}
const repo = state.repos?.find((r) => r.id === worktree.repoId)
return repo?.connectionId ?? null
return repo.connectionId ?? null
}

View File

@ -166,6 +166,24 @@ describe('safeFit', () => {
expect(activeBuffer.viewportY).toBe(42)
})
it('does not throw when xterm rejects scroll restoration during layout', () => {
const pane = createPane({
proposedCols: 100,
proposedRows: 32,
terminalCols: 120,
terminalRows: 32
})
const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number }
activeBuffer.viewportY = 42
activeBuffer.baseY = 100
vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => {
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
})
expect(() => safeFit(pane)).not.toThrow()
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
})
it('still refits when a split-scroll lock is active and the grid changed', () => {
const pane = createPane({
proposedCols: 100,

View File

@ -73,7 +73,12 @@ export function safeFit(pane: ManagedPane): void {
// Container may not have dimensions yet
} finally {
if (shouldRestoreScroll && scrollState) {
restoreScrollStateAfterLayout(pane.terminal, scrollState)
try {
restoreScrollStateAfterLayout(pane.terminal, scrollState)
} catch {
// Why: xterm can temporarily expose a terminal whose renderer has not
// initialized dimensions yet during SSH reattach/layout. Fit is best-effort.
}
}
}
}

View File

@ -54,6 +54,8 @@ describe('startup UI hydration fallback', () => {
expect(hydratePersistedUI.mock.calls[0][0].sidebarWidth).toBe(280)
expect(hydratePersistedUI.mock.calls[0][0].groupBy).toBe('workspace-status')
expect(hydratePersistedUI.mock.calls[0][0].sortBy).toBe('name')
expect(hydratePersistedUI.mock.calls[0][0].hideSleepingWorkspaces).toBe(false)
expect(hydratePersistedUI.mock.calls[0][0].showSleepingWorkspaces).toBe(true)
})
it('does not mark UI hydrated after the startup effect has been cancelled', () => {

View File

@ -1,4 +1,6 @@
import {
DEFAULT_HIDE_SLEEPING_WORKSPACES,
DEFAULT_SHOW_SLEEPING_WORKSPACES,
DEFAULT_STATUS_BAR_ITEMS,
DEFAULT_WORKTREE_CARD_PROPERTIES
} from '../../../shared/constants'
@ -37,7 +39,8 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
groupBy: 'workspace-status',
sortBy: 'name',
showActiveOnly: false,
showSleepingWorkspaces: false,
hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES,
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
hideDefaultBranchWorkspace: false,
filterRepoIds: [],
collapsedGroups: [],

View File

@ -54,6 +54,12 @@ function makePersistedUI(overrides: Partial<PersistedUIState> = {}): PersistedUI
}
describe('createUISlice hydratePersistedUI', () => {
it('defaults to showing sleeping workspaces', () => {
const store = createUIStore()
expect(store.getState().showSleepingWorkspaces).toBe(true)
})
it('preserves the current right sidebar width when older persisted UI omits it', () => {
const store = createUIStore()
@ -125,25 +131,37 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().showActiveOnly).toBe(false)
})
it('restores the show-sleeping filter from persisted UI state', () => {
it('restores the new hide-sleeping filter from persisted UI state', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
showSleepingWorkspaces: true
hideSleepingWorkspaces: true
})
)
expect(store.getState().showSleepingWorkspaces).toBe(false)
})
it('ignores legacy hidden-sleeping preference so existing users start with sleeping visible', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
showSleepingWorkspaces: false
})
)
expect(store.getState().showSleepingWorkspaces).toBe(true)
})
it('restores the legacy show-inactive filter as show-sleeping', () => {
it('ignores the legacy show-inactive filter so existing users start with sleeping visible', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
showSleepingWorkspaces: undefined,
showInactiveWorkspaces: true
showInactiveWorkspaces: false
})
)

View File

@ -34,6 +34,8 @@ import {
resolveVisibleTaskProvider
} from '../../../../shared/task-providers'
import {
DEFAULT_HIDE_SLEEPING_WORKSPACES,
DEFAULT_SHOW_SLEEPING_WORKSPACES,
DEFAULT_STATUS_BAR_ITEMS,
DEFAULT_WORKTREE_CARD_PROPERTIES,
normalizeWorktreeCardProperties
@ -885,7 +887,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
showActiveOnly: false,
setShowActiveOnly: (v) => set({ showActiveOnly: v }),
showSleepingWorkspaces: false,
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }),
hideDefaultBranchWorkspace: false,
@ -1099,12 +1101,10 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
// Why: Active-only was retired. Force the old persisted flag off so an
// old profile cannot invisibly keep narrowing the workspace list.
showActiveOnly: false,
// Why: a short-lived build called this "inactive"; keep that key as a
// fallback so the renamed sleeping filter preserves user intent.
showSleepingWorkspaces:
ui.showSleepingWorkspaces ??
(ui as PersistedUIState & { showInactiveWorkspaces?: boolean }).showInactiveWorkspaces ??
false,
// Why: `hideSleepingWorkspaces` is the canonical negative-form filter.
// Older positive-form keys are intentionally ignored so old profiles
// start from the new default: sleeping workspaces visible.
showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES),
hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false,
filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)),
collapsedGroups: new Set(ui.collapsedGroups ?? []),

View File

@ -26,6 +26,8 @@ export {
export const SCHEMA_VERSION = 1
export const DEFAULT_APP_FONT_FAMILY = 'Geist'
export const DEFAULT_SHOW_SLEEPING_WORKSPACES = true
export const DEFAULT_HIDE_SLEEPING_WORKSPACES = false
// Why: the onboarding wizard's last step index. Centralized so backfill,
// clamps, and UI step references all agree on the same upper bound.
@ -344,7 +346,8 @@ export function getDefaultUIState(): PersistedUIState {
groupBy: 'workspace-status',
sortBy: 'recent',
showActiveOnly: false,
showSleepingWorkspaces: false,
hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES,
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
hideDefaultBranchWorkspace: false,
filterRepoIds: [],
collapsedGroups: [],

View File

@ -1996,9 +1996,11 @@ export type PersistedUIState = {
sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual'
/** Deprecated; the Active only filter is retired and ignored on hydration. */
showActiveOnly: boolean
/** Off by default: sleeping/inactive workspaces stay hidden until shown. */
/** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */
hideSleepingWorkspaces?: boolean
/** Deprecated legacy positive-form setting. Ignored on hydration. */
showSleepingWorkspaces?: boolean
/** Legacy name for the same setting used by a short-lived build. */
/** Deprecated legacy name used by a short-lived build. Ignored on hydration. */
showInactiveWorkspaces?: boolean
/** Hide the repo's original checked-out branch from workspace navigation
* (sidebar and Cmd+J jump palette). Folder-mode repos are unaffected