mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
"Hide sleeping" swept each project's main workspace out of the sidebar as soon as it had no live PTY, browser tab or agent — even with "Hide default branch" off. For a project whose only row is that workspace (a folder workspace, a fresh clone, a detached-HEAD main), the entire project vanished with no in-place way back. Adds a shared `isSleepingSweepExemptWorkspace` predicate keyed on `isMainWorktree` rather than the branch name, so folder workspaces (no branch), detached-HEAD mains, and SSH rows whose head/branch are blanked while a provider is disconnected all stay put. Wired into `computeVisibleWorktreeIds` (sidebar, Cmd+1-9, workspace board), the jump palette's duplicate inline pass, and mobile's `filterWorktrees`. Ships default-on with an escape hatch: a persisted `alwaysShowDefaultBranchWorkspace` setting surfaced as "Except default branch" under "Hide sleeping". Explicit "Hide default branch" still wins, since it filters before the sleeping sweep. Mobile reads the setting but never writes it back, so a desktop opt-out can't be clobbered by a filter tap before the ui.get roundtrip lands. Combines the two PRs open against #8873. #8966's exempt set is a strict subset of this one, so its production diff was subsumed rather than ported; its jump-palette render harness and e2e spec were carried over, and are the only such coverage here. Fixes #8873 Closes #8966 Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: Orca <help@stably.ai>
787 lines
22 KiB
TypeScript
787 lines
22 KiB
TypeScript
import { join } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { Worktree } from './workspace-list-sections'
|
|
import {
|
|
CREATE_GRACE_MS,
|
|
buildSections,
|
|
filterWorktrees,
|
|
getWorktreeStatus,
|
|
sortWorktrees
|
|
} from './workspace-list-sections'
|
|
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
|
|
|
|
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
|
const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature')
|
|
return {
|
|
workspaceKind: 'git',
|
|
worktreeId: `repo-1::${worktreePath}`,
|
|
repoId: 'repo-1',
|
|
repo: 'orca',
|
|
branch: 'feature/mobile-parity',
|
|
displayName: 'feature',
|
|
path: worktreePath,
|
|
liveTerminalCount: 0,
|
|
hasAttachedPty: false,
|
|
preview: '',
|
|
unread: false,
|
|
isPinned: false,
|
|
linkedPR: null,
|
|
status: 'inactive',
|
|
agents: [],
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function withoutSectionListKeys(sections: ReturnType<typeof buildSections>) {
|
|
return sections.map((section) => ({
|
|
...section,
|
|
data: section.data.map(
|
|
({
|
|
sectionListKey: _sectionListKey,
|
|
lineageDepth: _lineageDepth,
|
|
lineageChildCount: _lineageChildCount,
|
|
lineageCollapsed: _lineageCollapsed,
|
|
isLastLineageChild: _isLastLineageChild,
|
|
...item
|
|
}) => item
|
|
)
|
|
}))
|
|
}
|
|
|
|
describe('filterWorktrees', () => {
|
|
it('hides archived worktrees', () => {
|
|
const visible = worktree({ worktreeId: 'visible' })
|
|
const archived = worktree({ worktreeId: 'archived', isArchived: true })
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[visible, archived],
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
''
|
|
)
|
|
).toEqual([visible])
|
|
})
|
|
|
|
it('uses host sidebar activity for sleeping filtering when available', () => {
|
|
const visible = worktree({
|
|
worktreeId: 'visible',
|
|
status: 'inactive',
|
|
liveTerminalCount: 0,
|
|
hasHostSidebarActivity: true
|
|
})
|
|
const retainedPtyOnly = worktree({
|
|
worktreeId: 'retained-pty-only',
|
|
status: 'active',
|
|
liveTerminalCount: 3,
|
|
hasHostSidebarActivity: false
|
|
})
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[visible, retainedPtyOnly],
|
|
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
|
|
''
|
|
)
|
|
).toEqual([visible])
|
|
})
|
|
|
|
it('uses the host-provided main-worktree flag for default branch hiding', () => {
|
|
const main = worktree({
|
|
worktreeId: 'main',
|
|
branch: 'main',
|
|
isMainWorktree: true
|
|
})
|
|
const featureNamedMain = worktree({
|
|
worktreeId: 'feature-main',
|
|
branch: 'main',
|
|
isMainWorktree: false
|
|
})
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[main, featureNamedMain],
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: true },
|
|
''
|
|
)
|
|
).toEqual([featureNamedMain])
|
|
})
|
|
|
|
it('keeps a sleeping main worktree visible under hide-sleeping by default (#8873)', () => {
|
|
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
|
|
const feature = worktree({ worktreeId: 'feature', isMainWorktree: false })
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[main, feature],
|
|
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
|
|
''
|
|
)
|
|
).toEqual([main])
|
|
})
|
|
|
|
it('keeps a sleeping folder workspace visible under hide-sleeping', () => {
|
|
const folder = worktree({
|
|
workspaceKind: 'folder-workspace',
|
|
worktreeId: 'folder:workspace-1',
|
|
branch: '',
|
|
isMainWorktree: true
|
|
})
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[folder],
|
|
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
|
|
''
|
|
)
|
|
).toEqual([folder])
|
|
})
|
|
|
|
it('re-hides the sleeping main worktree when the desktop setting is off', () => {
|
|
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[main],
|
|
{
|
|
filterRepoIds: new Set(),
|
|
hideSleeping: true,
|
|
hideDefaultBranch: false,
|
|
alwaysShowDefaultBranch: false
|
|
},
|
|
''
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('falls back to the branch heuristic for hosts that omit isMainWorktree', () => {
|
|
const legacyMain = worktree({ worktreeId: 'legacy-main', branch: 'refs/heads/master' })
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[legacyMain],
|
|
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
|
|
''
|
|
)
|
|
).toEqual([legacyMain])
|
|
})
|
|
|
|
it('keeps a sleeping folder workspace on hosts that omit isMainWorktree', () => {
|
|
// A folder workspace has no branch, so the legacy branch heuristic can never
|
|
// recognise it — without its own arm, #8873 still reproduces on old desktops.
|
|
const legacyFolder = worktree({
|
|
workspaceKind: 'folder-workspace',
|
|
worktreeId: 'folder:legacy-1',
|
|
branch: ''
|
|
})
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[legacyFolder],
|
|
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
|
|
''
|
|
)
|
|
).toEqual([legacyFolder])
|
|
})
|
|
|
|
it('lets hide-default-branch still win over the sleeping exemption', () => {
|
|
const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true })
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[main],
|
|
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: true },
|
|
''
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('keeps folder workspaces when default branch hiding is enabled', () => {
|
|
const folder = worktree({
|
|
workspaceKind: 'folder-workspace',
|
|
worktreeId: 'folder:workspace-1',
|
|
branch: '',
|
|
isMainWorktree: true
|
|
})
|
|
|
|
expect(
|
|
filterWorktrees(
|
|
[folder],
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: true },
|
|
''
|
|
)
|
|
).toEqual([folder])
|
|
})
|
|
})
|
|
|
|
describe('getWorktreeStatus', () => {
|
|
it('uses host sidebar inactivity for the row status dot when available', () => {
|
|
expect(
|
|
getWorktreeStatus(
|
|
worktree({
|
|
status: 'active',
|
|
liveTerminalCount: 3,
|
|
hasHostSidebarActivity: false
|
|
})
|
|
)
|
|
).toBe('inactive')
|
|
})
|
|
|
|
it('marks host sidebar activity active when runtime status has not caught up', () => {
|
|
expect(
|
|
getWorktreeStatus(
|
|
worktree({
|
|
status: 'inactive',
|
|
liveTerminalCount: 0,
|
|
hasHostSidebarActivity: true
|
|
})
|
|
)
|
|
).toBe('active')
|
|
})
|
|
})
|
|
|
|
describe('buildSections', () => {
|
|
it('matches desktop Name sort by display name', () => {
|
|
const beta = worktree({ worktreeId: 'beta', displayName: 'Beta', repo: 'aaa' })
|
|
const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', repo: 'zzz' })
|
|
|
|
expect(sortWorktrees([beta, alpha], 'name').map((item) => item.worktreeId)).toEqual([
|
|
'alpha',
|
|
'beta'
|
|
])
|
|
})
|
|
|
|
it('uses desktop manual order ranks in Manual sort mode', () => {
|
|
const low = worktree({ worktreeId: 'low', displayName: 'low', manualOrder: 10 })
|
|
const high = worktree({ worktreeId: 'high', displayName: 'high', manualOrder: 30 })
|
|
const fallback = worktree({ worktreeId: 'fallback', displayName: 'fallback', sortOrder: 20 })
|
|
|
|
const sections = buildSections(
|
|
[low, high, fallback],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'workspaceStatus',
|
|
new Set(),
|
|
new Map(),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
|
)
|
|
|
|
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual([
|
|
'high',
|
|
'fallback',
|
|
'low'
|
|
])
|
|
})
|
|
|
|
it('uses desktop display-name tie-breaks in Manual sort mode', () => {
|
|
const zed = worktree({ worktreeId: 'zed', displayName: 'Zed', manualOrder: 10 })
|
|
const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', manualOrder: 10 })
|
|
|
|
expect(sortWorktrees([zed, alpha], 'manual').map((item) => item.worktreeId)).toEqual([
|
|
'alpha',
|
|
'zed'
|
|
])
|
|
})
|
|
|
|
it('uses desktop-persisted smart order before mobile fallback signals', () => {
|
|
const desktopFirst = worktree({
|
|
worktreeId: 'desktop-first',
|
|
displayName: 'desktop-first',
|
|
sortOrder: 30,
|
|
status: 'inactive',
|
|
unread: false,
|
|
lastOutputAt: 1
|
|
})
|
|
const mobileFallbackFirst = worktree({
|
|
worktreeId: 'mobile-fallback-first',
|
|
displayName: 'mobile-fallback-first',
|
|
sortOrder: 10,
|
|
status: 'working',
|
|
unread: true,
|
|
lastOutputAt: 100
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[mobileFallbackFirst, desktopFirst],
|
|
'smart',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'workspaceStatus',
|
|
new Set(),
|
|
new Map(),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
|
)
|
|
|
|
expect(sections.flatMap((section) => section.data.map((item) => item.worktreeId))).toEqual([
|
|
'desktop-first',
|
|
'mobile-fallback-first'
|
|
])
|
|
})
|
|
|
|
it('uses desktop display-name tie-breaks for equal persisted smart ranks', () => {
|
|
const zed = worktree({ worktreeId: 'zed', displayName: 'Zed', sortOrder: 20, unread: true })
|
|
const alpha = worktree({
|
|
worktreeId: 'alpha',
|
|
displayName: 'Alpha',
|
|
sortOrder: 20,
|
|
status: 'working',
|
|
lastOutputAt: 100
|
|
})
|
|
|
|
expect(sortWorktrees([zed, alpha], 'smart').map((item) => item.worktreeId)).toEqual([
|
|
'alpha',
|
|
'zed'
|
|
])
|
|
})
|
|
|
|
it('counts live terminal output as Recent activity for headless serve hosts', () => {
|
|
// Serve hosts only stamp lastActivityAt at creation, so output must rank.
|
|
const streamingOnServe = worktree({
|
|
worktreeId: 'streaming',
|
|
displayName: 'streaming',
|
|
lastActivityAt: 100,
|
|
lastOutputAt: 1_000
|
|
})
|
|
const touchedOnDesktop = worktree({
|
|
worktreeId: 'touched',
|
|
displayName: 'touched',
|
|
lastActivityAt: 200,
|
|
lastOutputAt: 1
|
|
})
|
|
|
|
expect(
|
|
sortWorktrees([touchedOnDesktop, streamingOnServe], 'recent').map((item) => item.worktreeId)
|
|
).toEqual(['streaming', 'touched'])
|
|
})
|
|
|
|
it('keeps desktop activity ranking in Recent when it is newest', () => {
|
|
const newerActivity = worktree({
|
|
worktreeId: 'newer-activity',
|
|
displayName: 'newer',
|
|
lastActivityAt: 2_000,
|
|
lastOutputAt: 1
|
|
})
|
|
const quietOlder = worktree({
|
|
worktreeId: 'quiet-older',
|
|
displayName: 'quiet',
|
|
lastActivityAt: 100,
|
|
lastOutputAt: 150
|
|
})
|
|
|
|
expect(
|
|
sortWorktrees([quietOlder, newerActivity], 'recent').map((item) => item.worktreeId)
|
|
).toEqual(['newer-activity', 'quiet-older'])
|
|
})
|
|
|
|
it('falls back to agent attention order in Smart when no desktop ranks exist', () => {
|
|
// Display names are deliberately reverse-alphabetical to prove status ranks.
|
|
const idle = worktree({ worktreeId: 'idle', displayName: 'Aardvark' })
|
|
const needsPermission = worktree({
|
|
worktreeId: 'needs-permission',
|
|
displayName: 'Zebra',
|
|
status: 'permission'
|
|
})
|
|
const working = worktree({ worktreeId: 'working', displayName: 'Yak', status: 'working' })
|
|
|
|
expect(
|
|
sortWorktrees([idle, working, needsPermission], 'smart').map((item) => item.worktreeId)
|
|
).toEqual(['needs-permission', 'working', 'idle'])
|
|
})
|
|
|
|
it('keeps desktop-ranked rows above unranked attention-fallback rows in Smart', () => {
|
|
const ranked = worktree({ worktreeId: 'ranked', displayName: 'Ranked', sortOrder: 5 })
|
|
const unrankedWorking = worktree({
|
|
worktreeId: 'unranked-working',
|
|
displayName: 'Working',
|
|
status: 'working'
|
|
})
|
|
|
|
expect(
|
|
sortWorktrees([unrankedWorking, ranked], 'smart').map((item) => item.worktreeId)
|
|
).toEqual(['ranked', 'unranked-working'])
|
|
})
|
|
|
|
it('matches desktop Recent create grace for newly-created workspaces', () => {
|
|
const now = 10_000
|
|
const created = worktree({
|
|
worktreeId: 'created',
|
|
displayName: 'created',
|
|
lastActivityAt: 100,
|
|
createdAt: now - 1_000
|
|
})
|
|
const active = worktree({
|
|
worktreeId: 'active',
|
|
displayName: 'active',
|
|
lastActivityAt: now + CREATE_GRACE_MS - 2_000
|
|
})
|
|
|
|
expect(sortWorktrees([active, created], 'recent', now).map((item) => item.worktreeId)).toEqual([
|
|
'created',
|
|
'active'
|
|
])
|
|
})
|
|
|
|
it('matches desktop Repo sort by repo display name then workspace display name', () => {
|
|
const betaAlpha = worktree({
|
|
worktreeId: 'beta-alpha',
|
|
repo: 'Beta Repo',
|
|
displayName: 'Alpha'
|
|
})
|
|
const alphaZed = worktree({
|
|
worktreeId: 'alpha-zed',
|
|
repo: 'Alpha Repo',
|
|
displayName: 'Zed'
|
|
})
|
|
const betaBravo = worktree({
|
|
worktreeId: 'beta-bravo',
|
|
repo: 'Beta Repo',
|
|
displayName: 'Bravo'
|
|
})
|
|
|
|
expect(
|
|
sortWorktrees([betaBravo, betaAlpha, alphaZed], 'repo').map((item) => item.worktreeId)
|
|
).toEqual(['alpha-zed', 'beta-alpha', 'beta-bravo'])
|
|
})
|
|
|
|
it('uses desktop localeCompare semantics for Repo sort names', () => {
|
|
const accentRepo = worktree({
|
|
worktreeId: 'accent-repo',
|
|
repo: 'áb repo',
|
|
displayName: 'Alpha'
|
|
})
|
|
const plainRepo = worktree({
|
|
worktreeId: 'plain-repo',
|
|
repo: 'ab repo',
|
|
displayName: 'Zed'
|
|
})
|
|
|
|
expect(sortWorktrees([accentRepo, plainRepo], 'repo').map((item) => item.worktreeId)).toEqual([
|
|
'plain-repo',
|
|
'accent-repo'
|
|
])
|
|
})
|
|
|
|
it('keeps a desktop-ranked parent and child stack above unrelated active rows', () => {
|
|
const parent = worktree({
|
|
worktreeId: 'parent',
|
|
displayName: 'Add agents to mobile',
|
|
repo: 'orca',
|
|
sortOrder: 30,
|
|
status: 'inactive'
|
|
})
|
|
const child = worktree({
|
|
worktreeId: 'child',
|
|
displayName: 'Agent Session History resume (PR2)',
|
|
repo: 'orca',
|
|
parentWorktreeId: 'parent',
|
|
sortOrder: 20,
|
|
status: 'inactive'
|
|
})
|
|
const unrelatedActive = worktree({
|
|
worktreeId: 'active',
|
|
displayName: 'Overlapping tui output',
|
|
repo: 'orca',
|
|
sortOrder: 10,
|
|
status: 'working',
|
|
unread: true,
|
|
lastOutputAt: 100
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[unrelatedActive, child, parent],
|
|
'smart',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'repo',
|
|
new Set(),
|
|
new Map([['orca', 'repo-1']]),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
|
)
|
|
|
|
expect(sections[0]?.data.map((item) => item.worktreeId)).toEqual(['parent', 'child', 'active'])
|
|
expect(sections[0]?.data.map((item) => item.lineageDepth)).toEqual([0, 1, 0])
|
|
})
|
|
|
|
it('keeps the main workspace first inside repo-grouped sections like desktop', () => {
|
|
const child = worktree({
|
|
worktreeId: 'child',
|
|
displayName: 'Child',
|
|
repo: 'orca',
|
|
sortOrder: 30,
|
|
isMainWorktree: false
|
|
})
|
|
const main = worktree({
|
|
worktreeId: 'main',
|
|
displayName: 'Main',
|
|
repo: 'orca',
|
|
sortOrder: 10,
|
|
isMainWorktree: true
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[child, main],
|
|
'smart',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'repo',
|
|
new Set(),
|
|
new Map([['orca', 'repo-1']])
|
|
)
|
|
|
|
expect(sections[0]?.data.map((item) => item.worktreeId)).toEqual(['main', 'child'])
|
|
})
|
|
|
|
it('renders empty repo sections from repo placeholders in repo grouping', () => {
|
|
const sections = buildSections(
|
|
[worktree({ repoId: 'repo-1', repo: 'orca' })],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'repo',
|
|
new Set(),
|
|
new Map([
|
|
['orca', 'repo-1'],
|
|
['zoom-img', 'repo-missing']
|
|
])
|
|
)
|
|
|
|
expect(withoutSectionListKeys(sections)).toEqual([
|
|
{
|
|
key: 'repo:repo-1',
|
|
title: 'orca',
|
|
data: [worktree({ repoId: 'repo-1', repo: 'orca' })]
|
|
},
|
|
{ key: 'repo:repo-missing', title: 'zoom-img', data: [] }
|
|
])
|
|
})
|
|
|
|
it('does not render empty repo sections outside repo grouping', () => {
|
|
const sections = buildSections(
|
|
[],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'none',
|
|
new Set(),
|
|
new Map([['zoom-img', 'repo-missing']])
|
|
)
|
|
|
|
expect(withoutSectionListKeys(sections)).toEqual([])
|
|
})
|
|
|
|
it('applies repo filters and search to empty repo sections', () => {
|
|
const sections = buildSections(
|
|
[],
|
|
'manual',
|
|
{
|
|
filterRepoIds: new Set(['repo-matching', 'repo-hidden']),
|
|
hideSleeping: false,
|
|
hideDefaultBranch: false
|
|
},
|
|
'zoom',
|
|
'repo',
|
|
new Set(),
|
|
new Map([
|
|
['zoom-img', 'repo-matching'],
|
|
['repo', 'repo-hidden'],
|
|
['zoom-hidden', 'repo-unfiltered']
|
|
])
|
|
)
|
|
|
|
expect(withoutSectionListKeys(sections)).toEqual([
|
|
{ key: 'repo:repo-matching', title: 'zoom-img', data: [] }
|
|
])
|
|
})
|
|
|
|
it('does not add an empty repo section when all of its worktrees are filtered out', () => {
|
|
const sleeping = worktree({
|
|
repoId: 'repo-sleeping',
|
|
repo: 'sleeping-repo',
|
|
hasHostSidebarActivity: false
|
|
})
|
|
const sections = buildSections(
|
|
[sleeping],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false },
|
|
'',
|
|
'repo',
|
|
new Set(),
|
|
new Map([
|
|
['sleeping-repo', 'repo-sleeping'],
|
|
['empty-repo', 'repo-empty']
|
|
])
|
|
)
|
|
|
|
expect(withoutSectionListKeys(sections)).toEqual([
|
|
{ key: 'repo:repo-empty', title: 'empty-repo', data: [] }
|
|
])
|
|
})
|
|
|
|
it('groups by desktop workspace status labels and order', () => {
|
|
const review = worktree({
|
|
worktreeId: 'review',
|
|
workspaceStatus: 'in-review',
|
|
status: 'active'
|
|
})
|
|
const progress = worktree({
|
|
worktreeId: 'progress',
|
|
workspaceStatus: 'in-progress',
|
|
status: 'working'
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[progress, review],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'workspaceStatus',
|
|
new Set(),
|
|
new Map(),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
|
)
|
|
|
|
expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
|
|
{ key: 'workspace-status:in-review', title: 'In review' },
|
|
{ key: 'workspace-status:in-progress', title: 'In progress' }
|
|
])
|
|
})
|
|
|
|
it('falls back to the default status catalog when desktop sends none', () => {
|
|
const progress = worktree({
|
|
worktreeId: 'progress',
|
|
workspaceStatus: 'in-progress',
|
|
status: 'working'
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[progress],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'workspaceStatus',
|
|
new Set(),
|
|
new Map(),
|
|
[]
|
|
)
|
|
|
|
expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
|
|
{ key: 'workspace-status:in-progress', title: 'In progress' }
|
|
])
|
|
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['progress'])
|
|
})
|
|
|
|
it('keeps pinned worktrees in their canonical status group like desktop', () => {
|
|
const pinned = worktree({
|
|
worktreeId: 'pinned',
|
|
workspaceStatus: 'in-progress',
|
|
isPinned: true
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[pinned],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'workspaceStatus',
|
|
new Set(),
|
|
new Map(),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
|
)
|
|
|
|
expect(withoutSectionListKeys(sections)).toEqual([
|
|
{ key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] },
|
|
{ key: 'workspace-status:in-progress', title: 'In progress', data: [pinned] }
|
|
])
|
|
})
|
|
|
|
it('renders one sorted All section when grouping is off like desktop', () => {
|
|
const inactiveFirst = worktree({
|
|
worktreeId: 'inactive-first',
|
|
displayName: 'inactive-first',
|
|
manualOrder: 30,
|
|
status: 'inactive'
|
|
})
|
|
const activeSecond = worktree({
|
|
worktreeId: 'active-second',
|
|
displayName: 'active-second',
|
|
manualOrder: 10,
|
|
status: 'working'
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[activeSecond, inactiveFirst],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'none',
|
|
new Set(),
|
|
new Map(),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
|
)
|
|
|
|
expect(sections.map((section) => section.key)).toEqual(['all'])
|
|
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual([
|
|
'inactive-first',
|
|
'active-second'
|
|
])
|
|
})
|
|
|
|
it('nests child workspaces under visible parents in grouped sections', () => {
|
|
const parent = worktree({
|
|
worktreeId: 'parent',
|
|
displayName: 'parent',
|
|
workspaceStatus: 'in-progress'
|
|
})
|
|
const child = worktree({
|
|
worktreeId: 'child',
|
|
displayName: 'child',
|
|
parentWorktreeId: 'parent',
|
|
workspaceStatus: 'in-progress'
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[child, parent],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'workspaceStatus',
|
|
new Set(),
|
|
new Map(),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
|
)
|
|
|
|
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent', 'child'])
|
|
expect(sections[0]?.data.map((worktree) => worktree.lineageDepth)).toEqual([0, 1])
|
|
})
|
|
|
|
it('collapses child workspaces under lineage parent rows', () => {
|
|
const parent = worktree({
|
|
worktreeId: 'parent',
|
|
displayName: 'parent',
|
|
workspaceStatus: 'in-progress'
|
|
})
|
|
const child = worktree({
|
|
worktreeId: 'child',
|
|
displayName: 'child',
|
|
parentWorktreeId: 'parent',
|
|
workspaceStatus: 'in-progress'
|
|
})
|
|
|
|
const sections = buildSections(
|
|
[child, parent],
|
|
'manual',
|
|
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
|
|
'',
|
|
'workspaceStatus',
|
|
new Set(),
|
|
new Map(),
|
|
DEFAULT_MOBILE_WORKSPACE_STATUSES,
|
|
new Set(['workspace-lineage:parent'])
|
|
)
|
|
|
|
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent'])
|
|
expect(sections[0]?.data[0]?.lineageChildCount).toBe(1)
|
|
expect(sections[0]?.data[0]?.lineageCollapsed).toBe(true)
|
|
})
|
|
})
|