mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
* Show agent session history on mobile Bring the desktop "Agent Session History" panel to Orca Mobile as a per-worktree screen: browse past agent transcript sessions across the host with scope tabs (Workspace/Project/All), search, grouping, session cards, and tap-to-read message previews. The transcript scan previously ran only over Electron IPC, so mobile could not reach it. Expose it over the runtime RPC protocol mobile already speaks (aiVault.listSessions) so the scan runs on whichever host owns the transcripts — correct for local and SSH/remote hosts. Both the desktop IPC handler and the new RPC method share one cache, so opening the desktop panel and the mobile screen never double-scan. The pure filter/group/display logic is lifted into /shared (the renderer re-exports it) so the standalone mobile package can reuse it. Mobile narrows scoped tabs client-side by cwd path-prefix because the host scan treats scope paths as a widening union. Resume-from-mobile is intentionally a follow-up. * Fix mobile agent history list rendering and RPC authorization - Authorize aiVault.listSessions in the mobile RPC allowlist so the mobile client's call is not rejected before dispatch (without this the screen could never load sessions at runtime). - Name each SectionList section's rows `data` (the field React Native reads) instead of `cards`, fixing a type error and silent empty-section rendering. * Address review feedback on agent session history - Match quoted repo:/path: search operator values so labels and paths with spaces match (e.g. path:"/Users/ada/My Project"). - Hold a scoped tab in loading until the worktree list resolves instead of firing an unscoped fetch that briefly shows unrelated host history; proceed once loaded even if the worktree is absent (no stuck spinner). - Clear cached host capabilities on disconnect/host-switch and failed status.get so a capability-gated action can't linger for a host that doesn't support it. - Cover the real OrcaRuntimeService codex-home forwarding path and the quoted-operator parser with tests. * Hide redundant mobile current worktree badges Co-authored-by: Orca <help@stably.ai> * Resume agent sessions from mobile history (#6969) Co-authored-by: Orca <help@stably.ai> * Adapt merged seams to main's lint and reply-sender hardening Co-authored-by: Orca <help@stably.ai> * Cap mobile project-scope paths to the aiVault RPC bound Co-authored-by: Orca <help@stably.ai> * Share the aiVault scopePaths bound between the RPC schema and mobile Co-authored-by: Orca <help@stably.ai> * Guard shared AI Vault inflight cleanup against concurrent key replacement The extracted cache module's .finally() cleared inflight tracking unconditionally, dropping the if (inflightKey === key) guard its sibling outer cache kept: an older scan resolving after a different-key scan replaced the tracking would null the newer scan's dedup slot, so a re-request started a duplicate transcript rescan. Mirrors the sibling guard; the regression test flushes a macrotask so a reverted guard fails fast on the call count instead of hanging. Co-authored-by: Orca <help@stably.ai> * Harden aiVault.listSessions contract and gate mobile header entry on capability - Clamp scopePaths (64) instead of rejecting, cap limit at 2000, and make executionHostId optional so mobile can omit it; restamp per caller. - Retain successful mobile terminal-create mutation ids for 60s so resume retries dedupe after transient socket drops. - Gate the session-header Agent History action on the aiVault.v1 capability (mirrors the host-list action) so old hosts never show a dead-end entry. - Fix stale contract comments (scopePaths clamp semantics; filters move includes quoted repo:/path: operator parsing). * Add subagent field to session test fixtures after #7423 merge AiVaultSession.subagent became required on main; the five fixtures added on this branch predate it. Top-level scanned sessions carry null. --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
698 lines
20 KiB
TypeScript
698 lines
20 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 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)
|
|
})
|
|
})
|