"Hide sleeping" never hides a workspace with an open agent session (#7197) (#7511)

* Keep running-agent workspaces visible under "Hide sleeping" (#7197)

The "Hide sleeping" sidebar filter judged a workspace active only when it
had a live PTY (or a browser tab), so a workspace with a running agent
whose live-PTY entry was momentarily absent — an SSH reconnect grace
window, an unmounted pane, a remote surface not yet `ready`, or an
orchestration worker reporting before its tab is mirrored — was
classified as "sleeping" and hidden while its session was still open.

The smart sort already treats a fresh `agentStatusByPaneKey` entry as
"working" independent of live-PTY, so the filter and sort disagreed. Add
`getWorktreeIdsWithLiveAgent`, which derives the worktrees with an open
agent session from the live agent-status map (sleep/teardown drop those
entries via dropAgentStatusByWorktree, so slept/hibernated workspaces
still hide), and consult it in `hasActiveWorkspaceActivity`. Wire it
through the sidebar list, Cmd+J jump palette, and kanban board.

* fix(agent-status): align live workspace attribution

* fix(mobile): preserve live-agent workspace activity

* fix(mobile): prefer newest agent status source

* chore: restore unrelated benchmark formatting

* fix(mobile): resolve projected agent worktree ids

* perf(mobile): index projected worktree summaries

* fix(mobile): preserve projected activity under limits

* fix(mobile): preserve POSIX path identity

* perf(mobile): cache projected summary fallbacks

* fix(mobile): preserve remote path and priority contracts

* perf(mobile): index projected paths by host flavor

* test(mobile): prove projected path index keys

* perf(mobile): bound projected path fallback

* perf(mobile): reuse projected repo platforms

* test(mobile): enforce projected lookup bounds

* chore(runtime): remove review instrumentation

* perf(mobile): skip unresolved repo platform scans

* perf(mobile): batch represented project runtimes

* perf(mobile): batch cold project runtime scans

* fix(mobile): couple worktree platform snapshots

* fix(sidebar): prioritize attributed headless agents

* fix(sidebar): activate smart sort for headless agents

* fix(sidebar): prefer mirrored agent ownership

* fix(mobile): follow mirrored agent ownership

* fix(sidebar): resolve mirrored unstamped agents

---------

Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
Brennan Benson 2026-07-13 01:10:38 -07:00 committed by GitHub
parent e98bfd67c1
commit 0b65d725c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1864 additions and 182 deletions

View File

@ -189,7 +189,9 @@ describe('createWslWatcher', () => {
const controller = new AbortController()
controller.abort()
await expect(createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal)).rejects.toMatchObject({
await expect(
createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal)
).rejects.toMatchObject({
name: 'AbortError'
})
expect(spawnMock).not.toHaveBeenCalled()

View File

@ -33,6 +33,16 @@ export function areWorktreePathsEqual(
return left === right
}
export function worktreePathComparisonKey(pathValue: string, platform = process.platform): string {
if (looksLikePosixAbsolutePath(pathValue)) {
return `posix:${normalizePosixWorktreePathForComparison(pathValue, platform)}`
}
if (platform === 'win32' || isWindowsAbsolutePathLike(pathValue)) {
return `windows:${normalizeWindowsWorktreePathForComparison(pathValue)}`
}
return `posix:${normalizePosixWorktreePathForComparison(pathValue, platform)}`
}
export function dedupeWorktreesByPath<T extends { path: string }>(
worktrees: readonly T[],
platform = process.platform

View File

@ -1,5 +1,5 @@
import type { Store } from './persistence'
import type { Repo } from '../shared/types'
import type { Project, Repo } from '../shared/types'
import {
resolveProjectExecutionRuntime,
type ProjectExecutionRuntimeResolution
@ -21,6 +21,25 @@ function canResolveProjectRuntimeForWorktreeId(store: Store): boolean {
return canResolveProjectRuntimeForRepo(store) && typeof store.getRepo === 'function'
}
function resolveLocalProjectRuntime(
store: Store,
project: Project,
settings: ReturnType<Store['getSettings']> = store.getSettings()
): ProjectExecutionRuntimeResolution {
const wslAvailable = hasCachedWslAvailability()
? (getCachedWslAvailability() ?? undefined)
: undefined
const availableWslDistros = hasCachedWslDistros() ? getCachedWslDistros() : null
return resolveProjectExecutionRuntime({
appPlatform: process.platform,
projectId: project.id,
projectRuntimePreference: project.localWindowsRuntimePreference,
globalWindowsRuntimeDefault: settings.localWindowsRuntimeDefault,
wslAvailable,
availableWslDistros
})
}
export function resolveLocalProjectRuntimeForRepo(
store: Store,
repo: Repo
@ -35,18 +54,41 @@ export function resolveLocalProjectRuntimeForRepo(
if (!project) {
return undefined
}
const wslAvailable = hasCachedWslAvailability()
? (getCachedWslAvailability() ?? undefined)
: undefined
const availableWslDistros = hasCachedWslDistros() ? getCachedWslDistros() : null
return resolveProjectExecutionRuntime({
appPlatform: process.platform,
projectId: project.id,
projectRuntimePreference: project.localWindowsRuntimePreference,
globalWindowsRuntimeDefault: store.getSettings().localWindowsRuntimeDefault,
wslAvailable,
availableWslDistros
})
return resolveLocalProjectRuntime(store, project)
}
export function resolveLocalProjectRuntimesForRepos(
store: Store,
repos: readonly Repo[]
): ReadonlyMap<string, ProjectExecutionRuntimeResolution> {
const runtimeByRepoId = new Map<string, ProjectExecutionRuntimeResolution>()
if (!canResolveProjectRuntimeForRepo(store)) {
return runtimeByRepoId
}
const requestedRepoIds = new Set(
repos
.filter((repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID)
.map((repo) => repo.id)
)
if (requestedRepoIds.size === 0) {
return runtimeByRepoId
}
const settings = store.getSettings()
for (const project of store.getProjects()) {
const matchingRepoIds = project.sourceRepoIds.filter(
(repoId) => requestedRepoIds.has(repoId) && !runtimeByRepoId.has(repoId)
)
if (matchingRepoIds.length === 0) {
continue
}
// Why: one project runtime applies to every source repo in that project;
// resolving it once prevents mobile polls from rescanning project settings.
const runtime = resolveLocalProjectRuntime(store, project, settings)
for (const repoId of matchingRepoIds) {
runtimeByRepoId.set(repoId, runtime)
}
}
return runtimeByRepoId
}
export function resolveLocalProjectRuntimeForWorktreeId(

View File

@ -1,8 +1,12 @@
import type { Store } from './persistence'
import type { Repo } from '../shared/types'
import { resolveLocalProjectRuntimeForRepo } from './local-project-runtime-resolution'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
export { resolveLocalProjectRuntimeForRepo } from './local-project-runtime-resolution'
export {
resolveLocalProjectRuntimeForRepo,
resolveLocalProjectRuntimesForRepos
} from './local-project-runtime-resolution'
export type LocalProjectGitExecOptions = {
cwd: string
@ -19,7 +23,16 @@ export function getLocalProjectGitExecOptions(
): LocalProjectGitExecOptions {
// Why: local git must run in the same resolved project runtime as agents,
// terminals, and preflight; repair states must not silently fall back to host git.
const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo)
return getLocalProjectGitExecOptionsForRuntime(
repo,
resolveLocalProjectRuntimeForRepo(store, repo)
)
}
function getLocalProjectGitExecOptionsForRuntime(
repo: Repo,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): LocalProjectGitExecOptions {
if (!projectRuntime) {
return { cwd: repo.path }
}
@ -41,3 +54,13 @@ export function getLocalProjectWorktreeGitOptions(
const { wslDistro } = getLocalProjectGitExecOptions(store, repo)
return wslDistro ? { wslDistro } : {}
}
export function getLocalProjectWorktreeGitOptionsForRuntime(
repo: Repo,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): LocalProjectWorktreeGitOptions {
// Why: callers that already batch-resolved project runtimes must not rescan
// every project once per repo on a polling path.
const { wslDistro } = getLocalProjectGitExecOptionsForRuntime(repo, projectRuntime)
return wslDistro ? { wslDistro } : {}
}

View File

@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, win32 } from 'node:path'
import { ipcMain } from 'electron'
import type {
FolderWorkspace,
@ -75,6 +75,7 @@ import {
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import * as worktreePathComparison from '../ipc/worktree-path-comparison'
import * as localWorktreeFilesystem from '../local-worktree-filesystem'
import {
DEFAULT_REPO_BADGE_COLOR,
@ -21693,25 +21694,41 @@ describe('OrcaRuntimeService', () => {
})
})
it('reports the resolved terminal platform for WSL project mobile summaries', async () => {
it('resolves WSL platforms only for repos represented in mobile summaries', async () => {
await withPlatform('win32', async () => {
const primaryRepo = store.getRepos()[0]!
let repos = [
primaryRepo,
...Array.from({ length: 100 }, (_, index) => ({
...primaryRepo,
id: `repo-represented-${index}`,
path: `C:\\repo-represented-${index}`,
displayName: `repo-represented-${index}`
}))
]
const getProjects = vi.fn(() =>
repos.slice(0, 101).map((repo, index) => ({
id: `project-${index}`,
displayName: repo.displayName,
badgeColor: 'blue',
sourceRepoIds: [repo.id],
localWindowsRuntimePreference:
index === 0
? ({ kind: 'wsl' as const, distro: 'Ubuntu' } as const)
: ({ kind: 'windows-host' as const } as const),
createdAt: 0,
updatedAt: 0
}))
)
const getSettings = vi.fn(() => ({
...store.getSettings(),
localWindowsRuntimeDefault: { kind: 'windows-host' as const }
}))
const runtime = new OrcaRuntimeService({
...store,
getProjects: () => [
{
id: 'project-1',
displayName: 'repo',
badgeColor: 'blue',
sourceRepoIds: [TEST_REPO_ID],
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
createdAt: 0,
updatedAt: 0
}
],
getSettings: () => ({
...store.getSettings(),
localWindowsRuntimeDefault: { kind: 'windows-host' }
})
getRepos: () => repos,
getProjects,
getSettings
} as never)
const { worktrees } = await runtime.getWorktreePs()
@ -21720,6 +21737,70 @@ describe('OrcaRuntimeService', () => {
repoId: TEST_REPO_ID,
terminalPlatform: 'linux'
})
expect(getProjects).toHaveBeenCalledTimes(1)
expect(getSettings).toHaveBeenCalledTimes(2)
getProjects.mockClear()
getSettings.mockClear()
repos = [
...repos,
...Array.from({ length: 2_000 }, (_, index) => ({
...repos[0]!,
id: `repo-unresolved-${index}`,
path: `C:\\repo-unresolved-${index}`,
displayName: `repo-unresolved-${index}`
}))
]
await runtime.getWorktreePs()
// Why: the cache already owns the batch-resolved platforms for its 101
// worktree repos; newly persisted repos must not trigger another scan.
expect(getProjects).not.toHaveBeenCalled()
expect(getSettings).toHaveBeenCalledTimes(1)
})
})
it('keeps each worktree poll paired with its platform generation', async () => {
await withPlatform('win32', async () => {
let runtimePreference: { kind: 'wsl'; distro: string } | { kind: 'windows-host' } = {
kind: 'wsl',
distro: 'Ubuntu'
}
const runtimeStore = {
...store,
getProjects: () => [
{
id: 'project-generation',
displayName: 'generation',
badgeColor: 'blue',
sourceRepoIds: [TEST_REPO_ID],
localWindowsRuntimePreference: runtimePreference,
createdAt: 0,
updatedAt: 0
}
],
getSettings: () => ({
...store.getSettings(),
localWindowsRuntimeDefault: { kind: 'windows-host' as const }
})
}
const staleScan = deferred<typeof MOCK_GIT_WORKTREES>()
vi.mocked(listWorktrees)
.mockImplementationOnce(() => staleScan.promise)
.mockResolvedValueOnce(MOCK_GIT_WORKTREES)
const runtime = new OrcaRuntimeService(runtimeStore as never)
const stalePoll = runtime.getWorktreePs()
runtimePreference = { kind: 'windows-host' }
runtime.notifyBranchRenamed(TEST_REPO_ID)
const freshPoll = await runtime.getWorktreePs()
staleScan.resolve(MOCK_GIT_WORKTREES)
const staleResult = await stalePoll
// Why: invalidation can let a newer scan finish first. Each result must
// retain the platform map computed with its own worktree generation.
expect(staleResult.worktrees[0]).toMatchObject({ terminalPlatform: 'linux' })
expect(freshPoll.worktrees[0]).toMatchObject({ terminalPlatform: 'win32' })
})
})
@ -21869,6 +21950,7 @@ describe('OrcaRuntimeService', () => {
// worktree.ps reads the hook snapshot so mobile surfaces those agents too.
const leafId = '33333333-3333-4333-8333-333333333333'
const paneKey = `tab-1:${leafId}`
const now = Date.now()
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
@ -21880,8 +21962,8 @@ describe('OrcaRuntimeService', () => {
agentType: 'claude',
lastAssistantMessage: 'on it',
connectionId: null,
receivedAt: 1000,
stateStartedAt: 900
receivedAt: now,
stateStartedAt: now - 100
}
]
})
@ -21939,12 +22021,171 @@ describe('OrcaRuntimeService', () => {
taskTitle: 'Dispatch prompt work',
displayName: 'Review dispatch prompts and make worker labels distinct',
lastAssistantMessage: 'on it',
stateStartedAt: 900,
updatedAt: 1000
stateStartedAt: now - 100,
updatedAt: now
})
])
expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' })
})
it('uses mirrored tab ownership after a workspace rename instead of stale hook attribution', async () => {
const renamedPath = '/tmp/worktree-renamed'
const renamedWorktreeId = `${TEST_REPO_ID}::${renamedPath}`
vi.mocked(listWorktrees).mockResolvedValue([
...MOCK_GIT_WORKTREES,
{
path: renamedPath,
head: 'def',
branch: 'feature/renamed',
isBare: false,
isMainWorktree: false
}
])
const metaById = {
...store.getAllWorktreeMeta(),
[renamedWorktreeId]: makeWorktreeMeta({ displayName: 'renamed' })
}
const session = makeWorkspaceSessionWithHeadlessTerminal({
activeWorktreeId: renamedWorktreeId,
activeTabIdByWorktree: { [renamedWorktreeId]: 'host-tab' },
tabsByWorktree: {
[renamedWorktreeId]: [
{
id: 'host-tab',
ptyId: null,
worktreeId: renamedWorktreeId,
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
})
const runtimeStore = {
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
getWorkspaceSession: () => session
}
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: `host-tab:${HEADLESS_LEAF_ID}`,
worktreeId: TEST_WORKTREE_ID,
state: 'working',
prompt: 'continue after rename',
agentType: 'codex',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
]
})
const { worktrees } = await runtime.getWorktreePs()
const oldSummary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
const renamedSummary = worktrees.find((worktree) => worktree.worktreeId === renamedWorktreeId)
expect(oldSummary?.agents).toEqual([])
expect(renamedSummary).toMatchObject({
hasHostSidebarActivity: true,
status: 'working',
agents: [expect.objectContaining({ prompt: 'continue after rename' })]
})
})
it('keeps a fresh OSC row when the cached hook row for the same pane is older', async () => {
const now = Date.now()
const leafId = '44444444-4444-4444-8444-444444444444'
const paneKey = `tab-1:${leafId}`
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey,
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1',
state: 'working',
prompt: 'stale hook row',
agentType: 'claude',
connectionId: null,
receivedAt: now - AGENT_STATUS_STALE_AFTER_MS - 1,
stateStartedAt: now - AGENT_STATUS_STALE_AFTER_MS - 100
}
]
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
activeLeafId: leafId,
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
leafId,
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
runtime.onPtyData(
'pty-1',
'\x1b]9999;{"state":"working","prompt":"fresh OSC row","agentType":"codex"}\x07',
321
)
const { worktrees } = await runtime.getWorktreePs()
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' })
expect(summary?.agents).toEqual([
expect.objectContaining({ paneKey, prompt: 'fresh OSC row', agentType: 'codex' })
])
})
it.each([
['blocked', 0, true, 'permission'],
['waiting', 0, true, 'permission'],
['done', 0, false, 'inactive'],
['working', -AGENT_STATUS_STALE_AFTER_MS - 1, false, 'inactive']
] as const)(
'projects %s agent activity to mobile at freshness offset %s',
async (state, updatedAtOffset, hasHostSidebarActivity, status) => {
const now = Date.now()
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'tab-1:33333333-3333-4333-8333-333333333333',
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1',
state,
prompt: 'mobile parity',
agentType: 'codex',
connectionId: null,
receivedAt: now + updatedAtOffset,
stateStartedAt: now - 100
}
]
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({
hasHostSidebarActivity,
status
})
}
)
it('marks the desktop-active worktree as isActive', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
@ -21978,10 +22219,11 @@ describe('OrcaRuntimeService', () => {
displayName: 'Remote mobile'
})
}
const getRepo = vi.fn((id: string) => (id === remoteRepo.id ? remoteRepo : undefined))
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
getRepo,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
@ -21993,18 +22235,459 @@ describe('OrcaRuntimeService', () => {
listWorktrees: vi.fn().mockResolvedValue([remoteWorktree])
} as never)
const runtime = new OrcaRuntimeService(runtimeStore as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () =>
Array.from({ length: 100 }, (_, index) => ({
paneKey: `remote-tab:${String(index).padStart(8, '0')}-5555-4555-8555-555555555555`,
worktreeId: `${remoteRepo.id}::${remoteWorktree.path}/`,
tabId: 'remote-tab',
state: 'working',
prompt: 'remote agent without a PTY',
agentType: 'codex',
connectionId: 'ssh-1',
receivedAt: now,
stateStartedAt: now - 100
}))
})
const summaries = await runtime.getWorktreePs()
// Why: worktree.ps is polled; equal keys prove projected rows can share the
// per-request index instead of repeating compatibility path scans.
expect(worktreePathComparison.worktreePathComparisonKey(remoteWorktree.path, 'linux')).toBe(
worktreePathComparison.worktreePathComparisonKey(`${remoteWorktree.path}/`, 'linux')
)
expect(summaries.worktrees).toEqual([
expect.objectContaining({
worktreeId: `${remoteRepo.id}::${remoteWorktree.path}`,
repoId: remoteRepo.id,
repo: 'remote-vm',
path: remoteWorktree.path,
displayName: 'Remote mobile'
displayName: 'Remote mobile',
hasHostSidebarActivity: true,
status: 'working',
agents: expect.arrayContaining([
expect.objectContaining({
prompt: 'remote agent without a PTY',
agentType: 'codex'
})
])
})
])
expect(summaries.worktrees[0]?.agents).toHaveLength(100)
expect(getRepo).toHaveBeenCalledTimes(1)
})
it.each([
['relative', 'project', 'feature\\name', 'feature/name', 'feature\\name', true],
[
'absolute',
'C:\\remote',
'/remote/feature\\name',
'/remote/feature/name',
'/remote/feature\\name',
true
],
['Windows SSH alias', 'C:\\remote', 'feature\\name', 'feature/name', 'feature/name', false]
] as const)(
'handles %s worktree paths when projecting mobile agents',
async (_kind, repoPath, backslashPath, slashPath, projectedPath, includeSlashWorktree) => {
setPlatform('win32')
const remoteRepo = {
id: 'repo-relative-ssh',
path: repoPath,
displayName: 'relative-vm',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-relative'
}
const backslashWorktree = {
path: backslashPath,
head: 'abc',
branch: 'refs/heads/backslash',
isBare: false,
isMainWorktree: false
}
const slashWorktree = {
...backslashWorktree,
path: slashPath,
branch: 'refs/heads/slash'
}
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
}
registerSshGitProvider('ssh-relative', {
listWorktrees: vi
.fn()
.mockResolvedValue(
includeSlashWorktree ? [backslashWorktree, slashWorktree] : [backslashWorktree]
)
} as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () =>
Array.from({ length: 100 }, (_, index) => ({
paneKey: `relative-tab:${String(index).padStart(8, '0')}-5555-4555-8555-555555555555`,
worktreeId: `${remoteRepo.id}::${projectedPath}/`,
tabId: 'relative-tab',
state: 'working',
prompt: 'relative path agent',
agentType: 'codex',
connectionId: 'ssh-relative',
receivedAt: now,
stateStartedAt: now - 100
}))
})
const summaries = await runtime.getWorktreePs()
const backslashSummary = summaries.worktrees.find(
(worktree) => worktree.path === backslashWorktree.path
)
const slashSummary = summaries.worktrees.find(
(worktree) => worktree.path === slashWorktree.path
)
expect(backslashSummary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' })
expect(backslashSummary?.agents).toHaveLength(100)
expect(slashSummary?.agents).toEqual(includeSlashWorktree ? [] : undefined)
const comparisonPlatform = repoPath.startsWith('C:') ? 'win32' : 'linux'
const backslashKey = worktreePathComparison.worktreePathComparisonKey(
backslashPath,
comparisonPlatform
)
const slashKey = worktreePathComparison.worktreePathComparisonKey(
slashPath,
comparisonPlatform
)
expect(backslashKey === slashKey).toBe(!includeSlashWorktree)
}
)
it('projects 100 distinct pair-aware paths without rescanning the worktree list', async () => {
const remoteRepo = {
id: 'repo-pair-aware-scale',
path: '/remote',
displayName: 'pair-aware-scale-vm',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-pair-aware-scale'
}
let pathReadCount = 0
const remoteWorktrees = Array.from({ length: 100 }, (_, index) => {
const path = `C:relative\\feature-${String(index).padStart(3, '0')}`
return {
get path() {
pathReadCount += 1
return path
},
head: `head-${index}`,
branch: `refs/heads/feature-${index}`,
isBare: false,
isMainWorktree: false
}
})
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
}
registerSshGitProvider('ssh-pair-aware-scale', {
listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees)
} as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () =>
remoteWorktrees.map((worktree, index) => ({
paneKey: `pair-aware-tab:${String(index).padStart(8, '0')}-9999-4999-8999-999999999999`,
worktreeId: `${remoteRepo.id}::${win32.resolve(worktree.path)}`,
tabId: 'pair-aware-tab',
state: 'working' as const,
prompt: `pair-aware agent ${index}`,
agentType: 'codex',
connectionId: 'ssh-pair-aware-scale',
receivedAt: now,
stateStartedAt: now - 100
}))
})
const summaries = await runtime.getWorktreePs()
expect(summaries.worktrees).toHaveLength(100)
expect(summaries.worktrees.every((worktree) => worktree.agents?.length === 1)).toBe(true)
// Why: property reads make the scaling assertion mutation-sensitive without
// relying on a wall-clock threshold that varies across CI machines.
expect(pathReadCount).toBeLessThan(2_000)
})
it('bounds 2000 distinct malformed path misses per mobile poll', async () => {
const remoteRepo = {
id: 'repo-malformed-scale',
path: '/remote',
displayName: 'malformed-scale-vm',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-malformed-scale'
}
let pathReadCount = 0
const remoteWorktrees = Array.from({ length: 2_000 }, (_, index) => {
const path = `/remote/worktree-${String(index).padStart(4, '0')}`
return {
get path() {
pathReadCount += 1
return path
},
head: `head-${index}`,
branch: `refs/heads/worktree-${index}`,
isBare: false,
isMainWorktree: false
}
})
const getRepo = vi.fn((id: string) => (id === remoteRepo.id ? remoteRepo : undefined))
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
}
registerSshGitProvider('ssh-malformed-scale', {
listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees)
} as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () =>
Array.from({ length: 2_000 }, (_, index) => ({
paneKey: `malformed-tab:${String(index).padStart(8, '0')}-aaaa-4aaa-8aaa-aaaaaaaaaaaa`,
worktreeId: `${remoteRepo.id}::relative/./missing-${String(index).padStart(4, '0')}\\leaf`,
tabId: 'malformed-tab',
state: 'working' as const,
prompt: `missing agent ${index}`,
agentType: 'codex',
connectionId: 'ssh-malformed-scale',
receivedAt: now,
stateStartedAt: now - 100
}))
})
const summaries = await runtime.getWorktreePs()
expect(summaries).toMatchObject({ totalCount: 2_000, truncated: true })
expect(summaries.worktrees.every((worktree) => worktree.agents?.length === 0)).toBe(true)
expect(getRepo).toHaveBeenCalledTimes(remoteWorktrees.length)
// Why: the prior fallback read every worktree path for every distinct miss,
// exceeding the three-second worktree.ps polling interval at this scale.
expect(pathReadCount).toBeLessThan(40_000)
})
it('caches repeated malformed path misses before normalizing them again', async () => {
const remoteRepo = {
id: 'repo-repeated-miss',
path: '/remote',
displayName: 'repeated-miss-vm',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-repeated-miss'
}
const remoteWorktree = {
path: '/remote/existing',
head: 'head-existing',
branch: 'refs/heads/existing',
isBare: false,
isMainWorktree: false
}
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
}
registerSshGitProvider('ssh-repeated-miss', {
listWorktrees: vi.fn().mockResolvedValue([remoteWorktree])
} as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () =>
Array.from({ length: 2_000 }, (_, index) => ({
paneKey: `repeated-miss-tab:${String(index).padStart(8, '0')}-bbbb-4bbb-8bbb-bbbbbbbbbbbb`,
worktreeId: `${remoteRepo.id}::relative/./missing\\leaf`,
tabId: 'repeated-miss-tab',
state: 'working' as const,
prompt: `repeated missing agent ${index}`,
agentType: 'codex',
connectionId: 'ssh-repeated-miss',
receivedAt: now,
stateStartedAt: now - 100
}))
})
const cwdSpy = vi.spyOn(process, 'cwd')
try {
const summaries = await runtime.getWorktreePs()
expect(summaries.worktrees[0]?.agents).toEqual([])
// Why: resolving a relative comparison key consults cwd. A raw miss must
// do that once per poll, not once for every agent row carrying the ID.
expect(cwdSpy.mock.calls.length).toBeLessThan(50)
} finally {
cwdSpy.mockRestore()
}
})
it('keeps no-PTY agent worktrees in the truncated mobile summary', async () => {
const remoteRepo = {
id: 'repo-truncated-ssh',
path: '/remote',
displayName: 'truncated-vm',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-truncated'
}
const targetPath = '/remote/zzz-live-agent'
const remoteWorktrees = [
...Array.from({ length: 200 }, (_, index) => ({
path: `/remote/inactive-${String(index).padStart(3, '0')}`,
head: `head-${index}`,
branch: `refs/heads/inactive-${index}`,
isBare: false,
isMainWorktree: false
})),
{
path: targetPath,
head: 'live-agent',
branch: 'refs/heads/live-agent',
isBare: false,
isMainWorktree: false
}
]
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
}
registerSshGitProvider('ssh-truncated', {
listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees)
} as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'truncated-tab:77777777-7777-4777-8777-777777777777',
worktreeId: `${remoteRepo.id}::${targetPath}/`,
tabId: 'truncated-tab',
state: 'working',
prompt: 'live beyond the default limit',
agentType: 'codex',
connectionId: 'ssh-truncated',
receivedAt: now,
stateStartedAt: now - 100
}
]
})
const summaries = await runtime.getWorktreePs()
const target = summaries.worktrees.find((worktree) => worktree.path === targetPath)
expect(summaries).toMatchObject({ totalCount: 201, truncated: true })
expect(summaries.worktrees).toHaveLength(200)
expect(target).toMatchObject({ hasHostSidebarActivity: true, status: 'working' })
expect(target?.agents).toHaveLength(1)
})
it('keeps pinned and unread worktrees when active rows fill the mobile summary limit', async () => {
setPlatform('win32')
const remoteRepo = {
id: 'repo-pinned-limit',
path: '/remote',
displayName: 'pinned-limit-vm',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-pinned-limit'
}
const activeWorktrees = Array.from({ length: 199 }, (_, index) => ({
path: `relative/active-${String(index).padStart(3, '0')}`,
head: `head-${index}`,
branch: `refs/heads/active-${index}`,
isBare: false,
isMainWorktree: false
}))
const pinnedPath = 'relative/zzz-pinned'
const pinnedWorktree = {
path: pinnedPath,
head: 'pinned',
branch: 'refs/heads/pinned',
isBare: false,
isMainWorktree: false
}
const unreadPath = 'relative/zzz-unread'
const unreadWorktree = {
...pinnedWorktree,
path: unreadPath,
head: 'unread',
branch: 'refs/heads/unread'
}
const pinnedId = `${remoteRepo.id}::${pinnedPath}`
const unreadId = `${remoteRepo.id}::${unreadPath}`
const metaById: Record<string, WorktreeMeta> = {
[pinnedId]: makeWorktreeMeta({ isPinned: true }),
[unreadId]: makeWorktreeMeta({ isUnread: true })
}
const runtimeStore = {
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId]
}
registerSshGitProvider('ssh-pinned-limit', {
listWorktrees: vi.fn().mockResolvedValue([...activeWorktrees, pinnedWorktree, unreadWorktree])
} as never)
const now = Date.now()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getAgentStatusSnapshot: () =>
activeWorktrees.map((worktree, index) => ({
paneKey: `active-tab:${String(index).padStart(8, '0')}-8888-4888-8888-888888888888`,
worktreeId: `${remoteRepo.id}::${worktree.path.replace('relative/', 'relative/./')}`,
tabId: 'active-tab',
state: 'working' as const,
prompt: 'active row',
agentType: 'codex',
connectionId: 'ssh-pinned-limit',
receivedAt: now,
stateStartedAt: now - 100
}))
})
const summaries = await runtime.getWorktreePs()
expect(summaries).toMatchObject({ totalCount: 201, truncated: true })
expect(summaries.worktrees).toHaveLength(200)
expect(summaries.worktrees.find((worktree) => worktree.worktreeId === pinnedId)).toMatchObject({
isPinned: true,
hasHostSidebarActivity: false
})
expect(summaries.worktrees.find((worktree) => worktree.worktreeId === unreadId)).toMatchObject({
unread: true,
hasHostSidebarActivity: false
})
expect(
worktreePathComparison.worktreePathComparisonKey(activeWorktrees[0]!.path, 'linux')
).toBe(
worktreePathComparison.worktreePathComparisonKey(
activeWorktrees[0]!.path.replace('relative/', 'relative/./'),
'linux'
)
)
})
it('clears stale working status after the agent exits and the shell takes over the title', async () => {

View File

@ -27,6 +27,7 @@ import type {
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus,
type AgentStatusIpcPayload,
type ParsedAgentStatusPayload,
type AgentStatusOrchestrationContext,
@ -464,7 +465,9 @@ import {
import {
getLocalProjectGitExecOptions,
getLocalProjectWorktreeGitOptions,
resolveLocalProjectRuntimeForRepo
getLocalProjectWorktreeGitOptionsForRuntime,
resolveLocalProjectRuntimeForRepo,
resolveLocalProjectRuntimesForRepos
} from '../project-runtime-git-options'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import {
@ -688,6 +691,7 @@ import {
shouldSetDisplayName,
areWorktreePathsEqual
} from '../ipc/worktree-logic'
import { worktreePathComparisonKey } from '../ipc/worktree-path-comparison'
import {
assertWorktreeDoesNotContainRegisteredWorktree,
canCleanupUnregisteredOrcaLeftoverDirectory,
@ -2011,14 +2015,18 @@ class WorktreeIdRequiresFullPathError extends Error {
}
}
type ResolvedWorktreeCache = {
expiresAt: number
type ResolvedWorktreeSnapshot = {
worktrees: ResolvedWorktree[]
platformByRepoId: ReadonlyMap<string, NodeJS.Platform>
}
type ResolvedWorktreeCache = ResolvedWorktreeSnapshot & {
expiresAt: number
}
type ResolvedWorktreeInFlight = {
generation: number
promise: Promise<ResolvedWorktree[]>
promise: Promise<ResolvedWorktreeSnapshot>
}
export type MobileNotificationDispatchEvent = {
@ -10919,13 +10927,15 @@ export class OrcaRuntimeService {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const resolvedWorktrees = (await this.listResolvedWorktrees()).filter((worktree) =>
const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot()
const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) =>
this.isRuntimeWorktreeVisible(worktree)
)
// Why: worktree.ps backs the mobile sidebar, so it must use the same
// host-owned imported-worktree visibility gate as worktree.list/desktop.
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo]))
const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId
const summaries = new Map<string, RuntimeWorktreePsSummary>()
// Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix),
@ -10954,7 +10964,7 @@ export class OrcaRuntimeService {
if (!linkedPR && meta?.linkedPR != null) {
linkedPR = { number: meta.linkedPR, state: 'unknown' }
}
const terminalPlatform = repo ? this.getAgentLaunchPlatformForRepo(repo) : process.platform
const terminalPlatform = platformByRepoId.get(worktree.repoId) ?? process.platform
// Why: use the instance-validated lineage from attachLineageToResolvedWorktrees,
// not the raw store entry — shipped mobile clients trust parentWorktreeId as-is,
// so a stale same-path entry would nest replacement checkouts under old parents.
@ -11054,11 +11064,18 @@ export class OrcaRuntimeService {
})
}
const runtimeWorktreeSummaryPathIndex = buildRuntimeWorktreeSummaryPathIndex(
summaries,
resolvedWorktrees,
platformByRepoId
)
const missingRuntimeWorktreeIds = new Set<string>()
const countedPtyIds = new Set<string>()
for (const leaf of this.leaves.values()) {
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
resolvedWorktrees,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
leaf.worktreeId
)
if (!summary) {
@ -11092,7 +11109,8 @@ export class OrcaRuntimeService {
}
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
resolvedWorktrees,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
pty.worktreeId
)
if (!summary) {
@ -11116,7 +11134,12 @@ export class OrcaRuntimeService {
if (tabs.length === 0) {
continue
}
const summary = this.getSummaryForRuntimeWorktreeId(summaries, resolvedWorktrees, worktreeId)
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (!summary) {
continue
}
@ -11142,7 +11165,8 @@ export class OrcaRuntimeService {
if (session?.activeWorktreeId) {
const activeSummary = this.getSummaryForRuntimeWorktreeId(
summaries,
resolvedWorktrees,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
session.activeWorktreeId
)
if (activeSummary) {
@ -11150,7 +11174,26 @@ export class OrcaRuntimeService {
}
}
this.attachAgentRowsToSummaries(summaries)
const mirroredWorktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
mirroredWorktreeIdByTabId.set(tab.id, worktreeId)
}
}
// Why: a live renderer graph may precede persistence, but persisted tab
// ownership wins when an automatic workspace rename has already rekeyed it.
for (const [tabId, tab] of this.tabs) {
if (!mirroredWorktreeIdByTabId.has(tabId)) {
mirroredWorktreeIdByTabId.set(tabId, tab.worktreeId)
}
}
this.attachAgentRowsToSummaries(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
mirroredWorktreeIdByTabId
)
const sorted = [...summaries.values()].sort(compareWorktreePs)
return {
@ -11164,7 +11207,12 @@ export class OrcaRuntimeService {
// agent list, mirroring the desktop sidebar. Lineage parent is resolved from
// the orchestration db (paneKey-keyed), not the OSC payload, since spawn
// hierarchy is pane-level state tracked separately from terminal output.
private attachAgentRowsToSummaries(summaries: Map<string, RuntimeWorktreePsSummary>): void {
private attachAgentRowsToSummaries(
summaries: Map<string, RuntimeWorktreePsSummary>,
runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds: Set<string>,
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
): void {
// Why: most agents report via hooks (agent-hooks/server), not OSC, so the
// hook snapshot is the primary source — same one the desktop sidebar reads.
// OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey.
@ -11172,6 +11220,7 @@ export class OrcaRuntimeService {
string,
{
paneKey: string
tabId?: string
worktreeId?: string
state: ParsedAgentStatusPayload['state']
agentType: string | null
@ -11188,6 +11237,7 @@ export class OrcaRuntimeService {
const { payload } = snapshot
rowSources.set(snapshot.paneKey, {
paneKey: snapshot.paneKey,
tabId: snapshot.tabId,
worktreeId: snapshot.worktreeId,
state: payload.state,
agentType: payload.agentType ?? null,
@ -11201,8 +11251,15 @@ export class OrcaRuntimeService {
})
}
for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) {
const existing = rowSources.get(entry.paneKey)
// Why: hook rows win ties, but an older cached hook must not replace a
// fresh OSC status and make a running mobile workspace look inactive.
if (existing && existing.updatedAt > entry.receivedAt) {
continue
}
rowSources.set(entry.paneKey, {
paneKey: entry.paneKey,
tabId: entry.tabId,
worktreeId: entry.worktreeId,
state: entry.state,
agentType: entry.agentType ?? null,
@ -11220,9 +11277,23 @@ export class OrcaRuntimeService {
}
const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey()
const rowsByWorktree = new Map<string, RuntimeWorktreeAgentRow[]>()
const now = Date.now()
for (const src of rowSources.values()) {
const worktreeId = src.worktreeId
if (!worktreeId || !summaries.has(worktreeId)) {
// Why: hooks retain launch-time attribution across automatic workspace
// renames; the tab's current mirrored owner is authoritative when present.
const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId
const worktreeId =
(tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined) ?? src.worktreeId
if (!worktreeId) {
continue
}
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (!summary) {
continue
}
const taskTitle = orchestrationByPaneKey?.[src.paneKey]?.taskTitle ?? null
@ -11242,11 +11313,13 @@ export class OrcaRuntimeService {
stateStartedAt: src.stateStartedAt,
updatedAt: src.updatedAt
}
const rows = rowsByWorktree.get(worktreeId)
// Why: SSH/runtime projections can spell an equivalent path differently;
// bucket by the canonical summary id so mobile keeps the agent activity.
const rows = rowsByWorktree.get(summary.worktreeId)
if (rows) {
rows.push(row)
} else {
rowsByWorktree.set(worktreeId, [row])
rowsByWorktree.set(summary.worktreeId, [row])
}
}
for (const [worktreeId, rows] of rowsByWorktree) {
@ -11255,6 +11328,18 @@ export class OrcaRuntimeService {
const summary = summaries.get(worktreeId)
if (summary) {
summary.agents = rows
for (const row of rows) {
if (!isFreshNonDoneAgentStatus(row, now)) {
continue
}
// Why: worktree.ps is mobile's host-sidebar parity source, so a live
// agent must survive the same temporary PTY gaps as desktop.
summary.hasHostSidebarActivity = true
summary.status = mergeWorktreeStatus(
summary.status,
row.state === 'working' ? 'working' : 'permission'
)
}
}
}
}
@ -19682,12 +19767,16 @@ export class OrcaRuntimeService {
}
private async listResolvedWorktrees(): Promise<ResolvedWorktree[]> {
return (await this.listResolvedWorktreeSnapshot()).worktrees
}
private async listResolvedWorktreeSnapshot(): Promise<ResolvedWorktreeSnapshot> {
if (!this.store) {
return []
return { worktrees: [], platformByRepoId: new Map() }
}
const now = Date.now()
if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) {
return this.resolvedWorktreeCache.worktrees
return this.resolvedWorktreeCache
}
const generation = this.resolvedWorktreeGeneration
if (this.resolvedWorktreeInFlight?.generation === generation) {
@ -19705,14 +19794,22 @@ export class OrcaRuntimeService {
}
}
private async computeResolvedWorktrees(generation: number): Promise<ResolvedWorktree[]> {
private async computeResolvedWorktrees(generation: number): Promise<ResolvedWorktreeSnapshot> {
if (!this.store) {
return []
return { worktrees: [], platformByRepoId: new Map() }
}
const now = Date.now()
const metaById = this.store.getAllWorktreeMeta() ?? {}
const repos = this.store.getRepos()
const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos)
const platformByRepoId = new Map(
repos.map((repo) => [
repo.id,
getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id))
])
)
const perRepoWorktrees = await Promise.all(
this.store.getRepos().map(async (repo) => {
repos.map(async (repo) => {
if (isFolderRepo(repo)) {
return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({
...worktree,
@ -19733,7 +19830,7 @@ export class OrcaRuntimeService {
// Why: mobile startup RPCs share this path. A slow repo scan should
// degrade one repo's metadata, not block all terminal/session loading.
const scan = await withTimeout(
this.listRepoWorktreesForResolution(repo),
this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId),
RESOLVED_WORKTREE_REPO_TIMEOUT_MS,
{ ok: false, worktrees: [] }
)
@ -19776,10 +19873,11 @@ export class OrcaRuntimeService {
if (generation === this.resolvedWorktreeGeneration) {
this.resolvedWorktreeCache = {
worktrees,
platformByRepoId,
expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS
}
}
return worktrees
return { worktrees, platformByRepoId }
}
private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] {
@ -19864,13 +19962,19 @@ export class OrcaRuntimeService {
}
}
private async listRepoWorktreesForResolution(repo: Repo): Promise<RuntimeWorktreeScanResult> {
private async listRepoWorktreesForResolution(
repo: Repo,
projectRuntimeByRepoId?: ReadonlyMap<string, ProjectExecutionRuntimeResolution>
): Promise<RuntimeWorktreeScanResult> {
if (!repo.connectionId) {
const projectRuntime = projectRuntimeByRepoId
? projectRuntimeByRepoId.get(repo.id)
: resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
return {
ok: true,
worktrees: await listRepoWorktrees(
repo,
getLocalProjectWorktreeGitOptions(this.requireStore(), repo)
getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime)
)
}
}
@ -20191,23 +20295,34 @@ export class OrcaRuntimeService {
private getSummaryForRuntimeWorktreeId(
summaries: Map<string, RuntimeWorktreePsSummary>,
resolvedWorktrees: ResolvedWorktree[],
runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds: Set<string>,
runtimeWorktreeId: string
): RuntimeWorktreePsSummary | null {
const exact = summaries.get(runtimeWorktreeId)
if (exact) {
return exact
}
if (missingRuntimeWorktreeIds.has(runtimeWorktreeId)) {
return null
}
const parsed = parseRuntimeWorktreeId(runtimeWorktreeId)
if (!parsed) {
return null
}
const resolved = resolvedWorktrees.find(
(worktree) =>
worktree.repoId === parsed.repoId &&
areWorktreePathsEqual(worktree.path, parsed.worktreePath)
const comparisonPlatform =
runtimeWorktreeSummaryPathIndex.platformByRepoId.get(parsed.repoId) ?? process.platform
const indexed = findRuntimeWorktreeSummaryByPath(
runtimeWorktreeSummaryPathIndex,
parsed.repoId,
parsed.worktreePath,
comparisonPlatform
)
return resolved ? (summaries.get(resolved.id) ?? null) : null
if (indexed) {
return indexed
}
missingRuntimeWorktreeIds.add(runtimeWorktreeId)
return null
}
private buildTerminalSummary(
@ -26389,6 +26504,118 @@ function parseRuntimeWorktreeId(
return parsed
}
type RuntimeWorktreeSummaryPathCandidate = {
summary: RuntimeWorktreePsSummary
order: number
}
type RuntimeWorktreeSummaryPathIndex = {
platformByRepoId: ReadonlyMap<string, NodeJS.Platform>
posixAbsolute: Map<string, RuntimeWorktreeSummaryPathCandidate>
posixRelative: Map<string, RuntimeWorktreeSummaryPathCandidate>
windows: Map<string, RuntimeWorktreeSummaryPathCandidate>
windowsAbsolute: Map<string, RuntimeWorktreeSummaryPathCandidate>
}
function buildRuntimeWorktreeSummaryPathIndex(
summaries: ReadonlyMap<string, RuntimeWorktreePsSummary>,
resolvedWorktrees: readonly ResolvedWorktree[],
platformByRepoId: ReadonlyMap<string, NodeJS.Platform>
): RuntimeWorktreeSummaryPathIndex {
const index: RuntimeWorktreeSummaryPathIndex = {
platformByRepoId,
posixAbsolute: new Map(),
posixRelative: new Map(),
windows: new Map(),
windowsAbsolute: new Map()
}
for (const [order, worktree] of resolvedWorktrees.entries()) {
const summary = summaries.get(worktree.id)
if (!summary) {
continue
}
const platform = platformByRepoId.get(worktree.repoId) ?? process.platform
const candidate = { summary, order }
if (isPosixAbsoluteRuntimeWorktreePath(worktree.path)) {
setFirstRuntimeWorktreePathCandidate(
index.posixAbsolute,
runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform),
candidate
)
continue
}
const windowsKey = runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, 'win32')
setFirstRuntimeWorktreePathCandidate(index.windows, windowsKey, candidate)
if (isWindowsAbsolutePathLike(worktree.path)) {
setFirstRuntimeWorktreePathCandidate(index.windowsAbsolute, windowsKey, candidate)
} else if (platform !== 'win32') {
setFirstRuntimeWorktreePathCandidate(
index.posixRelative,
runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform),
candidate
)
}
}
return index
}
function findRuntimeWorktreeSummaryByPath(
index: RuntimeWorktreeSummaryPathIndex,
repoId: string,
worktreePath: string,
platform: NodeJS.Platform
): RuntimeWorktreePsSummary | null {
if (isPosixAbsoluteRuntimeWorktreePath(worktreePath)) {
return (
index.posixAbsolute.get(runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform))
?.summary ?? null
)
}
const windowsKey = runtimeWorktreeSummaryPathKey(repoId, worktreePath, 'win32')
if (platform === 'win32' || isWindowsAbsolutePathLike(worktreePath)) {
return index.windows.get(windowsKey)?.summary ?? null
}
const posixCandidate = index.posixRelative.get(
runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform)
)
const windowsCandidate = index.windowsAbsolute.get(windowsKey)
// Why: a malformed relative path can compare as POSIX against another
// relative path or as Windows against an absolute Windows path. Preserve the
// old pairwise scan's first-match result without rescanning every worktree.
if (!posixCandidate) {
return windowsCandidate?.summary ?? null
}
if (!windowsCandidate || posixCandidate.order < windowsCandidate.order) {
return posixCandidate.summary
}
return windowsCandidate.summary
}
function setFirstRuntimeWorktreePathCandidate(
candidates: Map<string, RuntimeWorktreeSummaryPathCandidate>,
key: string,
candidate: RuntimeWorktreeSummaryPathCandidate
): void {
if (!candidates.has(key)) {
candidates.set(key, candidate)
}
}
function isPosixAbsoluteRuntimeWorktreePath(worktreePath: string): boolean {
return worktreePath.startsWith('/') && !worktreePath.startsWith('//')
}
function runtimeWorktreeSummaryPathKey(
repoId: string,
worktreePath: string,
platform: NodeJS.Platform
): string {
return `${repoId}\0${worktreePathComparisonKey(worktreePath, platform)}`
}
function includeTargetResolvedWorktree(
resolvedWorktrees: ResolvedWorktree[],
targetWorktree: ResolvedWorktree | null
@ -26669,6 +26896,11 @@ function compareWorktreePs(
if (left.unread !== right.unread) {
return left.unread ? -1 : 1
}
// Why: worktree.ps is truncated for mobile, so host-visible activity must
// survive ahead of ordinary inactive rows without displacing pinned/unread.
if (left.hasHostSidebarActivity !== right.hasHostSidebarActivity) {
return left.hasHostSidebarActivity ? -1 : 1
}
const leftLast = left.lastOutputAt ?? -1
const rightLast = right.lastOutputAt ?? -1
if (leftLast !== rightLast) {

View File

@ -32,7 +32,7 @@ import {
isAutomationGeneratedWorkspace,
isDefaultBranchWorkspace
} from '@/components/sidebar/visible-worktrees'
import { isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { getLiveAgentStatusByWorktreeId, 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'
@ -379,6 +379,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
terminalLayoutsByTabId,
tabsByWorktree
} = useAppStore(useShallow((s) => selectPaletteStatusInputs(s, visible || statusInputsLingering)))
const agentStatusEpoch = useAppStore((s) =>
visible || statusInputsLingering ? s.agentStatusEpoch : 0
)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
@ -465,6 +468,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const hasQuery = deferredQuery.trim().length > 0
const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0
// Why: keep running-agent workspaces visible under "Hide sleeping" even when
// their live PTY is momentarily absent, matching the sidebar filter. #7197
const liveAgentActivity = useMemo(() => {
void agentStatusEpoch
const statusByWorktreeId = getLiveAgentStatusByWorktreeId(
agentStatusByPaneKey,
tabsByWorktree,
Date.now()
)
return { statusByWorktreeId, worktreeIds: new Set(statusByWorktreeId.keys()) }
}, [agentStatusByPaneKey, agentStatusEpoch, tabsByWorktree])
const worktreeIdsWithLiveAgent = liveAgentActivity.worktreeIds
// Why: the empty-query palette mirrors sidebar filters so opening Search
// starts from the same quiet list. Typed search switches to the global
// non-archived scope below.
@ -482,7 +498,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
}
if (
!showSleepingWorkspaces &&
isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree)
isInactiveWorkspace(
worktree.id,
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree,
worktreeIdsWithLiveAgent
)
) {
return false
}
@ -495,7 +517,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
hideDefaultBranchWorkspace,
ptyIdsByTabId,
showSleepingWorkspaces,
tabsByWorktree
tabsByWorktree,
worktreeIdsWithLiveAgent
]
)
@ -1825,7 +1848,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
tabsByWorktree[worktree.id] ?? [],
browserTabsByWorktree[worktree.id] ?? [],
ptyIdsByTabId,
runtimePaneTitlesByTabId
runtimePaneTitlesByTabId,
{ liveAgentStatus: liveAgentActivity.statusByWorktreeId.get(worktree.id) }
)
const statusLabel = getWorktreeStatusLabel(status)
const isCurrentWorktree = activeWorktreeId === worktree.id

View File

@ -71,6 +71,7 @@ import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort'
import {
buildAttentionByWorktree,
hasFreshAttributedAgentStatus,
type SmartClass,
type WorktreeAttention
} from './smart-attention'
@ -117,6 +118,7 @@ import {
setVisibleWorktreeIds,
sidebarHasActiveFilters
} from './visible-worktrees'
import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state'
import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos'
import {
getVisibleWorktreeBrowserActivityTabs,
@ -307,6 +309,7 @@ const SORT_SETTLE_MS = 3_000
const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500
const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = []
const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {}
const EMPTY_WORKTREE_ID_SET: ReadonlySet<string> = new Set()
const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {}
const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {}
const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {}
@ -5185,6 +5188,7 @@ const WorktreeList = React.memo(function WorktreeList({
const setSortBy = useAppStore((s) => s.setSortBy)
const projectOrderBy = useAppStore((s) => s.projectOrderBy)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0))
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
@ -5323,13 +5327,10 @@ const WorktreeList = React.memo(function WorktreeList({
return () => clearTimeout(timer)
}, [sortEpoch, debouncedSortEpoch, worktreeCount, sortBy])
// Why a latching ref: we need to distinguish "app just started, no PTYs
// have spawned yet" from "user closed all terminals mid-session." The
// former should use the persisted sortOrder; the latter should keep using
// the live smart score. A point-in-time `hasAnyLivePty` check conflates
// the two. This ref flips to true once any PTY is observed and never
// reverts, so the cold-start path is only used on actual cold start.
const sessionHasHadPty = useRef(false)
// Why a latching ref: persisted order is only a cold-start fallback. A live
// PTY or fresh attributed headless agent makes Smart authoritative, and it
// must stay authoritative after that activity ends.
const sessionHasHadLiveSmartSignal = useRef(false)
// ── Stable sort order ──────────────────────────────────────────
// The sort order is cached and only recomputed when `sortEpoch` changes
@ -5353,24 +5354,27 @@ const WorktreeList = React.memo(function WorktreeList({
const nonArchivedWorktrees = getAllWorktreesFromState(state).filter(
(worktree) => !worktree.isArchived
)
const now = Date.now()
// Why cold-start detection: smart-class resolution depends on the
// agent-status snapshot (agentStatusByPaneKey) hydrating from the hook
// server, which lands asynchronously after launch. Running the warm
// comparator before that arrives would collapse every worktree to Class 4
// and shuffle the sidebar against the comparator's tiebreakers. Restore
// the pre-shutdown order from the persisted sortOrder snapshot until any
// live PTY appears, then switch to the live class layer. See Edge case 8
// in docs/smart-worktree-order-redesign.md.
if (sortBy === 'smart' && !sessionHasHadPty.current) {
// the pre-shutdown order from the persisted sortOrder snapshot until a
// live PTY or attributed headless agent appears, then use live classes.
if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) {
// Why: `tabHasLivePty` (over `ptyIdsByTabId`) is the source of truth for
// liveness — slept terminals retain `tab.ptyId` as a wake hint, so reading
// it directly would falsely keep cold-start ordering off after restart.
const hasAnyLivePty = Object.values(state.tabsByWorktree)
.flat()
.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id))
if (hasAnyLivePty) {
sessionHasHadPty.current = true
if (
hasAnyLivePty ||
hasFreshAttributedAgentStatus(state.agentStatusByPaneKey, now, state.tabsByWorktree)
) {
sessionHasHadLiveSmartSignal.current = true
} else {
nonArchivedWorktrees.sort(
(a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b)
@ -5381,7 +5385,6 @@ const WorktreeList = React.memo(function WorktreeList({
}
const currentTabs = state.tabsByWorktree
const now = Date.now()
// Why precompute: this is the hot sidebar sort. Array.sort invokes the
// comparator O(N log N) times. Build the per-worktree attention map ONCE
// (O(E + N×T×H) where H = stateHistory length, bounded at 20) so the
@ -5498,10 +5501,10 @@ const WorktreeList = React.memo(function WorktreeList({
}, [sortBy])
// Persist the computed sort order so the sidebar can be restored after
// restart. Only persist during live sessions (sessionHasHadPty latched) —
// restart. Only persist during live sessions (live signal latched) —
// on cold start we are *reading* the persisted order, not overwriting it.
useEffect(() => {
if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadPty.current) {
if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) {
return
}
// Why: sortOrder is persisted in each host's worktreeMeta and enriched from
@ -5513,12 +5516,22 @@ const WorktreeList = React.memo(function WorktreeList({
// Flatten, filter, and apply stable sort order via the shared utility so
// the card order always matches the Cmd+19 shortcut numbering.
const visibleWorktrees = useMemo(() => {
void agentStatusEpoch
const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, {
filterRepoIds,
showSleepingWorkspaces,
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree,
// Why snapshot on agentStatusEpoch: membership must update immediately,
// while subscribing to the full map would repaint on every hook ping.
worktreeIdsWithLiveAgent: showSleepingWorkspaces
? EMPTY_WORKTREE_ID_SET
: getWorktreeIdsWithLiveAgent(
useAppStore.getState().agentStatusByPaneKey,
tabsByWorktree,
Date.now()
),
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
repoMap,
@ -5540,6 +5553,7 @@ const WorktreeList = React.memo(function WorktreeList({
return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null)
}, [
agentSendTargetWorktreeId,
agentStatusEpoch,
filterRepoIds,
showSleepingWorkspaces,
hideDefaultBranchWorkspace,

View File

@ -56,6 +56,8 @@ function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }):
stateStartedAt: overrides.stateStartedAt ?? overrides.updatedAt ?? NOW - 30_000,
agentType: overrides.agentType ?? 'codex',
paneKey: overrides.paneKey,
worktreeId: overrides.worktreeId,
tabId: overrides.tabId,
terminalTitle: overrides.terminalTitle,
stateHistory: overrides.stateHistory ?? [],
interrupted: overrides.interrupted
@ -417,6 +419,60 @@ describe('buildAttentionByWorktree', () => {
expect(map.get(w.id)).toEqual(IDLE)
})
it('uses fresh worktree attribution before a headless tab is mirrored', () => {
const w = makeWorktree('wt-1')
const key = paneKey('headless-tab', LEAF_1)
const entries = {
[key]: makeEntry({
paneKey: key,
worktreeId: w.id,
tabId: 'headless-tab',
state: 'blocked',
stateStartedAt: NOW - 5_000,
updatedAt: NOW - 1_000
})
}
expect(buildAttentionByWorktree([w], {}, entries, {}, {}, NOW).get(w.id)).toEqual({
cls: 1,
attentionTimestamp: NOW - 5_000,
cause: 'blocked'
})
})
it('prefers mirrored tab ownership over a stale worktree stamp', () => {
const stale = makeWorktree('stale-worktree')
const current = makeWorktree('current-worktree')
const tab = makeTab('tab-1', current.id)
const key = paneKey(tab.id, LEAF_1)
const entries = {
[key]: makeEntry({
paneKey: key,
worktreeId: stale.id,
tabId: tab.id,
state: 'blocked',
stateStartedAt: NOW - 5_000,
updatedAt: NOW - 1_000
})
}
const attention = buildAttentionByWorktree(
[stale, current],
{ [current.id]: [tab] },
entries,
{},
ptyMap([tab.id]),
NOW
)
expect(attention.get(stale.id)).toEqual(IDLE)
expect(attention.get(current.id)).toEqual({
cls: 1,
attentionTimestamp: NOW - 5_000,
cause: 'blocked'
})
})
it('aggregates entries across multiple panes on the same tab', () => {
const w = makeWorktree('wt-1')
const tab = makeTab('tab-1', w.id)

View File

@ -57,6 +57,32 @@ export type WorktreeAttention = {
export const IDLE: WorktreeAttention = { cls: 4, attentionTimestamp: 0 }
export function hasFreshAttributedAgentStatus(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
now: number,
tabsByWorktree: Record<string, TerminalTab[]>
): boolean {
const freshUnstampedTabIds = new Set<string>()
for (const entry of Object.values(agentStatusByPaneKey ?? {})) {
const parsed = parsePaneKey(entry.paneKey)
if (parsed === null || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
continue
}
if (entry.worktreeId) {
return true
}
// Why: hook rows can omit the redundant stamp while paneKey still maps to
// a mirrored tab, which is enough to end the Smart cold-start fallback.
freshUnstampedTabIds.add(parsed.tabId)
}
if (freshUnstampedTabIds.size === 0) {
return false
}
return Object.values(tabsByWorktree).some((tabs) =>
tabs.some((tab) => freshUnstampedTabIds.has(tab.id))
)
}
/**
* Walk a pane's state-history rows and return the timestamp of the most
* recent `done`/`blocked`/`waiting` entry, ignoring `done` rows that were
@ -237,6 +263,24 @@ export function buildExplicitEntriesByTabId(
return byTab
}
function buildExplicitEntriesByWorktreeId(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
): Map<string, AgentStatusEntry[]> {
const byWorktree = new Map<string, AgentStatusEntry[]>()
for (const entry of Object.values(agentStatusByPaneKey ?? {})) {
if (!entry.worktreeId || !parsePaneKey(entry.paneKey)) {
continue
}
const bucket = byWorktree.get(entry.worktreeId)
if (bucket) {
bucket.push(entry)
} else {
byWorktree.set(entry.worktreeId, [entry])
}
}
return byWorktree
}
/**
* Extract the stable leaf id from a `${tabId}:${leafId}` paneKey. Used for
* per-pane authority: we need to know which leaves already have a fresh hook
@ -269,15 +313,27 @@ export function buildAttentionByWorktree(
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
): Map<string, WorktreeAttention> {
const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey, migrationUnsupportedByPtyId)
const byAttributedWorktree = buildExplicitEntriesByWorktreeId(agentStatusByPaneKey)
const mirroredTabIds = new Set(
Object.values(tabsByWorktree ?? {}).flatMap((tabs) => tabs.map((tab) => tab.id))
)
const result = new Map<string, WorktreeAttention>()
for (const worktree of worktrees) {
const tabs = tabsByWorktree?.[worktree.id]
if (!tabs || tabs.length === 0) {
result.set(worktree.id, IDLE)
const tabs = tabsByWorktree?.[worktree.id] ?? []
// Why: hook stamps can arrive before the renderer mirrors a headless or
// remote tab. Once mirrored anywhere, live tab ownership is authoritative
// over a stale worktree stamp and must not promote both worktrees.
const panes: PaneInput[] = (byAttributedWorktree.get(worktree.id) ?? [])
.filter((entry) => {
const parsed = parsePaneKey(entry.paneKey)
return parsed !== null && !mirroredTabIds.has(parsed.tabId)
})
.map((entry) => ({ kind: 'hook' as const, entry }))
if (tabs.length === 0) {
result.set(worktree.id, resolveAttention(panes, now))
continue
}
const panes: PaneInput[] = []
for (const tab of tabs) {
const hookEntries = byTab.get(tab.id)
// Why: leaf ids covered by a hook entry skip the title fallback so we

View File

@ -79,6 +79,8 @@ function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }):
stateStartedAt: overrides.stateStartedAt ?? overrides.updatedAt ?? NOW - 30_000,
agentType: overrides.agentType ?? 'codex',
paneKey: overrides.paneKey,
worktreeId: overrides.worktreeId,
tabId: overrides.tabId,
terminalTitle: overrides.terminalTitle,
stateHistory: overrides.stateHistory ?? [],
interrupted: overrides.interrupted
@ -457,6 +459,62 @@ describe('sortWorktreesSmart — cold start fallback', () => {
expect(sorted.map((w) => w.id)).toEqual(['b', 'a'])
})
it('uses fresh attributed agents before their headless tabs are mirrored', () => {
const blocked = makeWorktree({ id: 'blocked', displayName: 'Blocked', sortOrder: 0 })
const persistedFirst = makeWorktree({
id: 'persisted-first',
displayName: 'Persisted first',
sortOrder: 100
})
const key = paneKey('headless-tab')
const entries = {
[key]: makeEntry({
paneKey: key,
worktreeId: blocked.id,
tabId: 'headless-tab',
state: 'blocked',
stateStartedAt: Date.now() - 1_000,
updatedAt: Date.now()
})
}
const sorted = sortWorktreesSmart([persistedFirst, blocked], {}, repoMap, entries, {}, {})
expect(sorted.map((worktree) => worktree.id)).toEqual(['blocked', 'persisted-first'])
})
it('uses a fresh agent resolved through its mirrored tab without a worktree stamp', () => {
const blocked = makeWorktree({ id: 'blocked', displayName: 'Blocked', sortOrder: 0 })
const persistedFirst = makeWorktree({
id: 'persisted-first',
displayName: 'Persisted first',
sortOrder: 100
})
const key = paneKey('mirrored-tab')
const tabsByWorktree = {
[blocked.id]: [makeTab({ id: 'mirrored-tab', worktreeId: blocked.id })]
}
const entries = {
[key]: makeEntry({
paneKey: key,
state: 'blocked',
stateStartedAt: Date.now() - 1_000,
updatedAt: Date.now()
})
}
const sorted = sortWorktreesSmart(
[persistedFirst, blocked],
tabsByWorktree,
repoMap,
entries,
{},
{}
)
expect(sorted.map((worktree) => worktree.id)).toEqual(['blocked', 'persisted-first'])
})
it('falls back to the path label when a persisted worktree has no displayName', () => {
const missingDisplayName = {
...makeWorktree({

View File

@ -5,7 +5,12 @@ import type {
} from '../../../../shared/agent-status-types'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { basename } from '@/lib/path'
import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention'
import {
IDLE,
buildAttentionByWorktree,
hasFreshAttributedAgentStatus,
type WorktreeAttention
} from './smart-attention'
export type SortBy = 'name' | 'smart' | 'recent' | 'repo' | 'manual'
@ -161,7 +166,8 @@ export function sortWorktreesSmart(
.flat()
.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id))
if (!hasAnyLivePty) {
const now = Date.now()
if (!hasAnyLivePty && !hasFreshAttributedAgentStatus(agentStatusByPaneKey, now, tabsByWorktree)) {
// Cold start: use persisted sortOrder snapshot until the agent-status
// snapshot lands and a warm sort runs.
return [...worktrees].sort(
@ -169,7 +175,6 @@ export function sortWorktreesSmart(
)
}
const now = Date.now()
const attentionByWorktree = buildAttentionByWorktree(
worktrees,
tabsByWorktree,

View File

@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { useAppStore } from '@/store'
import type { Repo, Worktree } from '../../../../shared/types'
import { computeVisibleWorktreeIds } from './visible-worktrees'
import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state'
import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host'
type UseVisibleWorkspaceKanbanWorktreeIdsParams = {
@ -9,6 +10,8 @@ type UseVisibleWorkspaceKanbanWorktreeIdsParams = {
repoMap: Map<string, Repo>
}
const EMPTY_WORKTREE_ID_SET: ReadonlySet<string> = new Set()
export function useVisibleWorkspaceKanbanWorktreeIds({
allWorktrees,
repoMap
@ -26,6 +29,19 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
const browserTabsByWorktree = useAppStore((s) =>
!showSleepingWorkspaces ? s.browserTabsByWorktree : null
)
const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0))
// Why snapshot on the epoch: the always-mounted drawer must not scan every
// agent on unrelated store writes; membership changes advance this tick.
const worktreeIdsWithLiveAgent = useMemo(() => {
void agentStatusEpoch
return !showSleepingWorkspaces
? getWorktreeIdsWithLiveAgent(
useAppStore.getState().agentStatusByPaneKey,
tabsByWorktree,
Date.now()
)
: EMPTY_WORKTREE_ID_SET
}, [agentStatusEpoch, showSleepingWorkspaces, tabsByWorktree])
return useMemo(() => {
// Why: the board has its own status ordering, but visibility must match
@ -38,6 +54,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree,
worktreeIdsWithLiveAgent,
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
repoMap,
@ -62,6 +79,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
repoMap,
showSleepingWorkspaces,
tabsByWorktree,
worktreeIdsWithLiveAgent,
worktreesByRepo
])
}

View File

@ -79,6 +79,7 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions
tabsByWorktree: {},
ptyIdsByTabId: {},
browserTabsByWorktree: {},
worktreeIdsWithLiveAgent: new Set(),
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
repoMap,
@ -179,6 +180,24 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([])
})
it('keeps a running-agent worktree visible without a live pty when sleeping is hidden (#7197)', () => {
const wt = makeWorktree('wt-agent')
const result = computeVisibleWorktreeIds(
{ repo1: [wt] },
[wt.id],
visibleOptions({
showSleepingWorkspaces: false,
// No live PTY for the tab, but the agent session is live.
tabsByWorktree: { [wt.id]: [makeTab('tab-agent', wt.id, null)] },
ptyIdsByTabId: { 'tab-agent': [] },
worktreeIdsWithLiveAgent: new Set([wt.id])
})
)
expect(result).toEqual([wt.id])
})
it('hides paired web host terminal mirrors while their stream handle is pending', () => {
const wt = makeWorktree('wt-web-pending')

View File

@ -1,6 +1,6 @@
import type { Worktree, Repo, TerminalTab, WorktreeLineage } from '../../../../shared/types'
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
import { isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { useAppStore } from '@/store'
import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
@ -110,6 +110,9 @@ export function computeVisibleWorktreeIds(
tabsByWorktree: Record<string, Pick<TerminalTab, 'id'>[]> | null
ptyIdsByTabId: Record<string, string[]> | null
browserTabsByWorktree?: Record<string, { id: string }[]> | null
// Why required: every filter caller must preserve running agents through
// temporary PTY gaps instead of silently reverting #7197.
worktreeIdsWithLiveAgent: ReadonlySet<string>
// 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
@ -171,7 +174,8 @@ export function computeVisibleWorktreeIds(
w.id,
opts.tabsByWorktree,
opts.ptyIdsByTabId,
opts.browserTabsByWorktree
opts.browserTabsByWorktree,
opts.worktreeIdsWithLiveAgent
)
)
}
@ -305,6 +309,11 @@ export function getVisibleWorktreeIds(): string[] {
tabsByWorktree: state.tabsByWorktree,
ptyIdsByTabId: state.ptyIdsByTabId,
browserTabsByWorktree: state.browserTabsByWorktree,
worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent(
state.agentStatusByPaneKey,
state.tabsByWorktree,
Date.now()
),
hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
repoMap,

View File

@ -1,12 +1,16 @@
import type { AppState } from '@/store'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import {
mergeAgentStatusOrchestration,
parseAgentStatusPaneIdentity,
resolveAgentStatusWorktreeId
} from '@/lib/agent-status-worktree-attribution'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
export type WorktreeAgentActivitySummary = {
hasPermission: boolean
@ -94,18 +98,15 @@ function getWorktreeAgentActivitySummaries(
const now = Date.now()
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
const paneIdentity = parseAgentStatusPaneKey(paneKey)
const paneIdentity = parseAgentStatusPaneIdentity(paneKey)
if (!paneIdentity) {
continue
}
const orchestration = resolveEntryOrchestration(
const orchestration = mergeAgentStatusOrchestration(
entry,
runtimeAgentOrchestrationByPaneKey?.[paneKey]
)
const worktreeId =
tabIdToWorktreeId.get(paneIdentity.tabId) ??
entry.worktreeId ??
worktreeIdForPaneKey(orchestration?.parentPaneKey, tabIdToWorktreeId)
const worktreeId = resolveAgentStatusWorktreeId(entry, tabIdToWorktreeId, orchestration)
if (!worktreeId || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
continue
}
@ -128,11 +129,11 @@ function getWorktreeAgentActivitySummaries(
for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) {
const summary = summaryForWorktree(retained.worktreeId)
summary.hasRetainedDone = true
const paneIdentity = parseAgentStatusPaneKey(retained.entry?.paneKey)
const paneIdentity = parseAgentStatusPaneIdentity(retained.entry?.paneKey)
if (paneIdentity) {
addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId)
}
const orchestration = resolveEntryOrchestration(
const orchestration = mergeAgentStatusOrchestration(
retained.entry,
runtimeAgentOrchestrationByPaneKey?.[retained.entry.paneKey]
)
@ -204,25 +205,6 @@ function agentStatusPaneIdsByTabIdEqual(
return true
}
function resolveEntryOrchestration(
entry: Pick<AgentStatusEntry, 'orchestration'>,
runtimeOrchestration: AgentStatusOrchestrationContext | undefined
): AgentStatusOrchestrationContext | undefined {
if (!entry.orchestration) {
return runtimeOrchestration
}
if (!runtimeOrchestration) {
return entry.orchestration
}
if (
entry.orchestration.taskId === runtimeOrchestration.taskId &&
entry.orchestration.dispatchId === runtimeOrchestration.dispatchId
) {
return { ...entry.orchestration, ...runtimeOrchestration }
}
return entry.orchestration
}
function applyLiveAgentState(
summary: WorktreeAgentActivitySummary,
entry: Pick<AgentStatusEntry, 'state'>
@ -256,7 +238,7 @@ function worktreeIdForPaneKey(
paneKey: string | undefined,
tabIdToWorktreeId: Map<string, string>
): string | null {
const paneIdentity = parseAgentStatusPaneKey(paneKey)
const paneIdentity = parseAgentStatusPaneIdentity(paneKey)
return paneIdentity ? (tabIdToWorktreeId.get(paneIdentity.tabId) ?? null) : null
}
@ -266,7 +248,7 @@ function addParentPaneId(
worktreeId: string,
tabIdToWorktreeId: Map<string, string>
): void {
const parentPaneIdentity = parseAgentStatusPaneKey(orchestration?.parentPaneKey)
const parentPaneIdentity = parseAgentStatusPaneIdentity(orchestration?.parentPaneKey)
if (!parentPaneIdentity) {
return
}
@ -278,21 +260,3 @@ function addParentPaneId(
}
addAgentStatusPaneId(summary, parentPaneIdentity.tabId, parentPaneIdentity.paneId)
}
function parseAgentStatusPaneKey(
paneKey: string | undefined
): { tabId: string; paneId: string } | null {
if (!paneKey) {
return null
}
const parsed = parsePaneKey(paneKey)
if (parsed) {
return { tabId: parsed.tabId, paneId: parsed.leafId }
}
const legacy = parseLegacyNumericPaneKey(paneKey)
// Why: imported/restored agent rows can still carry pre-UUID pane keys.
// Keep their numeric pane id so the matching runtime title cannot revive
// a stale spinner after the row reports done.
return legacy ? { tabId: legacy.tabId, paneId: legacy.numericPaneId } : null
}

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import {
parseAgentStatusPaneIdentity,
resolveAgentStatusWorktreeId
} from './agent-status-worktree-attribution'
function entry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
state: 'working',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
...overrides
}
}
describe('agent status worktree attribution', () => {
it('uses the pane tab before a stale worktree stamp', () => {
expect(
resolveAgentStatusWorktreeId(
entry({ worktreeId: 'stale-worktree' }),
new Map([['tab-1', 'current-worktree']])
)
).toBe('current-worktree')
})
it('falls back to a parent pane tab for a pre-mirror worker', () => {
expect(
resolveAgentStatusWorktreeId(
entry({
paneKey: 'worker-tab:22222222-2222-4222-8222-222222222222',
orchestration: {
taskId: 'task-1',
dispatchId: 'dispatch-1',
parentPaneKey: 'parent-tab:1'
}
}),
new Map([['parent-tab', 'parent-worktree']])
)
).toBe('parent-worktree')
})
it('parses legacy numeric pane identities consistently', () => {
expect(parseAgentStatusPaneIdentity('tab-1:7')).toEqual({ tabId: 'tab-1', paneId: '7' })
})
})

View File

@ -0,0 +1,53 @@
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext
} from '../../../shared/agent-status-types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
export type AgentStatusPaneIdentity = { tabId: string; paneId: string }
export function parseAgentStatusPaneIdentity(
paneKey: string | undefined
): AgentStatusPaneIdentity | null {
if (!paneKey) {
return null
}
const parsed = parsePaneKey(paneKey)
if (parsed) {
return { tabId: parsed.tabId, paneId: parsed.leafId }
}
const legacy = parseLegacyNumericPaneKey(paneKey)
return legacy ? { tabId: legacy.tabId, paneId: legacy.numericPaneId } : null
}
export function resolveAgentStatusWorktreeId(
entry: Pick<AgentStatusEntry, 'paneKey' | 'worktreeId' | 'orchestration'>,
worktreeIdByTabId: ReadonlyMap<string, string>,
orchestration = entry.orchestration
): string | null {
const paneIdentity = parseAgentStatusPaneIdentity(entry.paneKey)
const parentIdentity = parseAgentStatusPaneIdentity(orchestration?.parentPaneKey)
return (
worktreeIdByTabId.get(paneIdentity?.tabId ?? '') ??
entry.worktreeId ??
worktreeIdByTabId.get(parentIdentity?.tabId ?? '') ??
null
)
}
export function mergeAgentStatusOrchestration(
entry: Pick<AgentStatusEntry, 'orchestration'>,
runtimeOrchestration: AgentStatusOrchestrationContext | undefined
): AgentStatusOrchestrationContext | undefined {
if (!entry.orchestration) {
return runtimeOrchestration
}
if (
!runtimeOrchestration ||
entry.orchestration.taskId !== runtimeOrchestration.taskId ||
entry.orchestration.dispatchId !== runtimeOrchestration.dispatchId
) {
return entry.orchestration
}
return { ...entry.orchestration, ...runtimeOrchestration }
}

View File

@ -1,28 +1,51 @@
import { describe, expect, it } from 'vitest'
import { hasActiveWorkspaceActivity, isInactiveWorkspace } from './worktree-activity-state'
import {
getLiveAgentStatusByWorktreeId,
getWorktreeIdsWithLiveAgent,
hasActiveWorkspaceActivity,
isInactiveWorkspace
} from './worktree-activity-state'
import type { TerminalTab } from '../../../shared/types'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
const NOW = 10_000_000
function makeTab(id: string): Pick<TerminalTab, 'id'> {
return { id }
}
function makeAgentEntry(
overrides: Partial<AgentStatusEntry> & { paneKey: string }
): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: NOW,
stateStartedAt: NOW,
stateHistory: [],
...overrides
}
}
describe('worktree activity state', () => {
it('treats a slept wake-hint workspace as inactive', () => {
expect(isInactiveWorkspace('wt-1', { 'wt-1': [makeTab('tab-1')] }, { 'tab-1': [] }, {})).toBe(
true
)
expect(
isInactiveWorkspace('wt-1', { 'wt-1': [makeTab('tab-1')] }, { 'tab-1': [] }, {}, new Set())
).toBe(true)
})
it('treats a never-opened workspace as inactive', () => {
expect(isInactiveWorkspace('wt-1', {}, {}, {})).toBe(true)
expect(isInactiveWorkspace('wt-1', {}, {}, {}, new Set())).toBe(true)
})
it('treats live terminal workspaces as active', () => {
const tabsByWorktree = { 'wt-1': [makeTab('tab-1')] }
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
expect(isInactiveWorkspace('wt-1', tabsByWorktree, ptyIdsByTabId, {})).toBe(false)
expect(hasActiveWorkspaceActivity('wt-1', tabsByWorktree, ptyIdsByTabId, {})).toBe(true)
expect(isInactiveWorkspace('wt-1', tabsByWorktree, ptyIdsByTabId, {}, new Set())).toBe(false)
expect(hasActiveWorkspaceActivity('wt-1', tabsByWorktree, ptyIdsByTabId, {}, new Set())).toBe(
true
)
})
it('treats browser workspaces as active', () => {
@ -31,14 +54,21 @@ describe('worktree activity state', () => {
'wt-1',
{ 'wt-1': [makeTab('tab-1')] },
{ 'tab-1': [] },
{ 'wt-1': [{ id: 'browser-1' }] }
{ 'wt-1': [{ id: 'browser-1' }] },
new Set()
)
).toBe(false)
})
it('treats pending paired web host terminal mirrors as inactive without a live pty', () => {
expect(
hasActiveWorkspaceActivity('wt-1', { 'wt-1': [makeTab('web-terminal-host-tab-1')] }, {}, {})
hasActiveWorkspaceActivity(
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{},
{},
new Set()
)
).toBe(false)
})
@ -48,7 +78,8 @@ describe('worktree activity state', () => {
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{ 'web-terminal-host-tab-1': ['pty-1'] },
{}
{},
new Set()
)
).toBe(true)
})
@ -59,8 +90,137 @@ describe('worktree activity state', () => {
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{},
{ 'wt-1': [{ id: 'browser-1' }] }
{ 'wt-1': [{ id: 'browser-1' }] },
new Set()
)
).toBe(true)
})
it('keeps a workspace with a running agent active even without a live pty (#7197)', () => {
const worktreeIdsWithLiveAgent = new Set(['wt-1'])
expect(
hasActiveWorkspaceActivity(
'wt-1',
{ 'wt-1': [makeTab('tab-1')] },
{ 'tab-1': [] },
{},
worktreeIdsWithLiveAgent
)
).toBe(true)
expect(
isInactiveWorkspace(
'wt-1',
{ 'wt-1': [makeTab('tab-1')] },
{ 'tab-1': [] },
{},
worktreeIdsWithLiveAgent
)
).toBe(false)
})
it('still hides a slept workspace with no live agent entry', () => {
expect(
isInactiveWorkspace('wt-1', { 'wt-1': [makeTab('tab-1')] }, { 'tab-1': [] }, {}, new Set())
).toBe(true)
})
})
describe('getWorktreeIdsWithLiveAgent', () => {
it('returns an empty set when there are no agent entries', () => {
expect(getWorktreeIdsWithLiveAgent({}, {}, NOW)).toEqual(new Set())
expect(getWorktreeIdsWithLiveAgent(null, null, NOW)).toEqual(new Set())
})
it('attributes an entry by its main-stamped worktreeId', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({ paneKey: 'tab-1:leaf-1', worktreeId: 'wt-1' })
}
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set(['wt-1']))
})
it('falls back to the paneKey tabId when worktreeId is absent', () => {
const entries = {
'tab-1:00000000-0000-4000-8000-000000000000': makeAgentEntry({
paneKey: 'tab-1:00000000-0000-4000-8000-000000000000'
})
}
expect(getWorktreeIdsWithLiveAgent(entries, { 'wt-1': [makeTab('tab-1')] }, NOW)).toEqual(
new Set(['wt-1'])
)
})
it('ignores entries that cannot be attributed to any worktree', () => {
const entries = {
'orphan:00000000-0000-4000-8000-000000000000': makeAgentEntry({
paneKey: 'orphan:00000000-0000-4000-8000-000000000000'
})
}
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set())
})
it('ignores completed headless agents without an open session', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
state: 'done'
})
}
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set())
})
it('ignores stale status left behind after an SSH disconnect', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
updatedAt: 0
})
}
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set())
})
it.each(['working', 'blocked', 'waiting'] as const)(
'keeps a fresh %s agent visible during a PTY gap',
(state) => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
state
})
}
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set(['wt-1']))
}
)
it('reports working and permission states with permission taking priority', () => {
const entries = {
'tab-1:leaf-1': makeAgentEntry({
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
state: 'working'
}),
'tab-2:leaf-2': makeAgentEntry({
paneKey: 'tab-2:leaf-2',
worktreeId: 'wt-2',
state: 'working'
}),
'tab-3:leaf-3': makeAgentEntry({
paneKey: 'tab-3:leaf-3',
worktreeId: 'wt-2',
state: 'blocked'
})
}
expect(getLiveAgentStatusByWorktreeId(entries, {}, NOW)).toEqual(
new Map([
['wt-1', 'working'],
['wt-2', 'permission']
])
)
})
})

View File

@ -1,5 +1,10 @@
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type { TerminalTab } from '../../../shared/types'
import {
isFreshNonDoneAgentStatus,
type AgentStatusEntry
} from '../../../shared/agent-status-types'
import { resolveAgentStatusWorktreeId } from './agent-status-worktree-attribution'
type TerminalLikeTab = Pick<TerminalTab, 'id'>
type BrowserLikeTab = { id: string }
@ -7,30 +12,82 @@ type BrowserLikeTab = { id: string }
type TabsByWorktree = Record<string, readonly TerminalLikeTab[]>
type PtyIdsByTabId = Record<string, string[]>
type BrowserTabsByWorktree = Record<string, readonly BrowserLikeTab[]>
export type LiveAgentWorktreeStatus = 'working' | 'permission'
/**
* Worktree ids that currently have a live agent session, derived from the
* live `agentStatusByPaneKey` map.
*
* Why only fresh in-progress rows: disconnected SSH and completed headless
* agents can retain status entries without an open session.
*/
export function getWorktreeIdsWithLiveAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | null | undefined,
tabsByWorktree: TabsByWorktree | null | undefined,
now: number
): Set<string> {
return new Set(getLiveAgentStatusByWorktreeId(agentStatusByPaneKey, tabsByWorktree, now).keys())
}
export function getLiveAgentStatusByWorktreeId(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | null | undefined,
tabsByWorktree: TabsByWorktree | null | undefined,
now: number
): Map<string, LiveAgentWorktreeStatus> {
const entries = Object.values(agentStatusByPaneKey ?? {}).filter((entry) =>
isFreshNonDoneAgentStatus(entry, now)
)
if (entries.length === 0) {
return new Map()
}
const worktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree ?? {})) {
for (const tab of tabs) {
worktreeIdByTabId.set(tab.id, worktreeId)
}
}
const result = new Map<string, LiveAgentWorktreeStatus>()
for (const entry of entries) {
const worktreeId = resolveAgentStatusWorktreeId(entry, worktreeIdByTabId)
if (worktreeId) {
const status = entry.state === 'working' ? 'working' : 'permission'
if (status === 'permission' || !result.has(worktreeId)) {
result.set(worktreeId, status)
}
}
}
return result
}
export function hasActiveWorkspaceActivity(
worktreeId: string,
tabsByWorktree: TabsByWorktree | null | undefined,
ptyIdsByTabId: PtyIdsByTabId | null | undefined,
browserTabsByWorktree: BrowserTabsByWorktree | null | undefined
browserTabsByWorktree: BrowserTabsByWorktree | null | undefined,
worktreeIdsWithLiveAgent: ReadonlySet<string>
): boolean {
const tabs = tabsByWorktree?.[worktreeId] ?? []
const hasLiveTerminal =
ptyIdsByTabId != null && tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id))
const hasBrowser = (browserTabsByWorktree?.[worktreeId] ?? []).length > 0
return hasLiveTerminal || hasBrowser
// Why: a running agent keeps the workspace visible through brief PTY gaps
// such as an SSH reconnect or an unmounted remote pane. #7197
const hasLiveAgent = worktreeIdsWithLiveAgent.has(worktreeId)
return hasLiveTerminal || hasBrowser || hasLiveAgent
}
export function isInactiveWorkspace(
worktreeId: string,
tabsByWorktree: TabsByWorktree | null | undefined,
ptyIdsByTabId: PtyIdsByTabId | null | undefined,
browserTabsByWorktree: BrowserTabsByWorktree | null | undefined
browserTabsByWorktree: BrowserTabsByWorktree | null | undefined,
worktreeIdsWithLiveAgent: ReadonlySet<string>
): boolean {
return !hasActiveWorkspaceActivity(
worktreeId,
tabsByWorktree,
ptyIdsByTabId,
browserTabsByWorktree
browserTabsByWorktree,
worktreeIdsWithLiveAgent
)
}

View File

@ -47,6 +47,18 @@ describe('getWorktreeStatus', () => {
expect(status).toBe('active')
})
it('preserves a working agent status without a renderer PTY', () => {
const status = getWorktreeStatus([], [], {}, {}, { liveAgentStatus: 'working' })
expect(status).toBe('working')
})
it('preserves a permission agent status without a renderer PTY', () => {
const status = getWorktreeStatus([], [], {}, {}, { liveAgentStatus: 'permission' })
expect(status).toBe('permission')
})
it('returns inactive when neither tabs nor browser state are live', () => {
expect(getWorktreeStatus([], [], {})).toBe('inactive')
})

View File

@ -7,10 +7,12 @@ import type {
TerminalPaneLayoutNode,
TerminalTab
} from '../../../shared/types'
import type { LiveAgentWorktreeStatus } from './worktree-activity-state'
export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive'
type WorktreeStatusHeuristicOptions = {
liveAgentStatus?: LiveAgentWorktreeStatus
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
@ -50,17 +52,15 @@ export function getWorktreeStatus(
const hasStatus = (status: 'permission' | 'working'): boolean =>
liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status, options))
if (hasStatus('permission')) {
if (options.liveAgentStatus === 'permission' || hasStatus('permission')) {
return 'permission'
}
if (hasStatus('working')) {
if (options.liveAgentStatus === 'working' || hasStatus('working')) {
return 'working'
}
if (liveTabs.length > 0 || browserTabs.length > 0) {
// Why: browser-only worktrees are still active from the user's point of
// view even when they have no PTY-backed terminal. The sidebar filter
// already treats them as active, so every navigation surface must reuse
// that rule instead of showing a misleading inactive dot.
// view even when they have no PTY-backed terminal.
return 'active'
}
return 'inactive'

View File

@ -37,6 +37,8 @@ vi.mock('../store', () => ({
vi.mock('./web-session-tabs-sync', () => ({
applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot,
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) =>
mocks.setState(buildPatch),
resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab
}))

View File

@ -265,8 +265,9 @@ async function refreshWebRuntimeSessionTabsSnapshot(
const snapshot = unwrapRuntimeRpcResult(
response as RuntimeRpcResponse<RuntimeMobileSessionTabsResult>
)
const { applyFreshWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
useAppStore.setState((state) => {
const { applyFreshWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } =
await import('./web-session-tabs-sync')
applyWebSessionTabsStorePatch((state) => {
// Why: eager refreshes can resolve after the user has selected another
// worktree; session parity should update tabs without stealing focus.
const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, environmentId)

View File

@ -1654,6 +1654,57 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.sortEpoch).toBe(1)
})
it('bumps aggregate epochs when a mirrored same-state entry gains attribution', () => {
const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID)
const snapshot = makeSnapshot([
{
type: 'terminal',
id: HOST_SURFACE_ID,
title: 'codex [working]',
parentTabId: 'host-tab-1',
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-1',
agentStatus: {
state: 'working',
prompt: 'fix web parity',
updatedAt: NOW - 100,
stateStartedAt: NOW - 1_000,
agentType: 'codex',
paneKey: hostPaneKey,
worktreeId: WT,
tabId: 'host-tab-1',
stateHistory: []
}
}
])
const initial = applyWebSessionTabsSnapshot(
makeState(),
snapshot,
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
const mirroredPaneKey = Object.keys(initial.agentStatusByPaneKey ?? {})[0]!
const existing = initial.agentStatusByPaneKey![mirroredPaneKey]!
const patch = applyWebSessionTabsSnapshot(
makeState({
...initial,
agentStatusByPaneKey: {
[mirroredPaneKey]: { ...existing, worktreeId: 'stale-worktree', tabId: 'stale-tab' }
},
agentStatusEpoch: 7,
sortEpoch: 11
}),
{ ...snapshot, snapshotVersion: 2 },
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
expect(patch.agentStatusEpoch).toBe(8)
expect(patch.sortEpoch).toBe(12)
})
it('keeps mirrored OMP tabs from repainting to Pi-compatible titles', () => {
const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID)
const patch = applyWebSessionTabsSnapshot(

View File

@ -685,6 +685,7 @@ function buildMirroredAgentStatusPatch(
let nextAgentStatusByPaneKey = state.agentStatusByPaneKey
let changed = false
let aggregateRelevantChange = false
let sortRelevantChange = false
for (const paneKey of Object.keys(state.agentStatusByPaneKey)) {
@ -699,6 +700,7 @@ function buildMirroredAgentStatusPatch(
}
delete nextAgentStatusByPaneKey[paneKey]
changed = true
aggregateRelevantChange = true
sortRelevantChange = true
}
@ -712,12 +714,16 @@ function buildMirroredAgentStatusPatch(
}
nextAgentStatusByPaneKey[paneKey] = entry
changed = true
sortRelevantChange =
sortRelevantChange ||
const entryAttributionChanged =
existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId
const entrySortRelevantChange =
!existing ||
existing.state !== entry.state ||
!isAgentStatusFresh(existing, now) ||
entryAttributionChanged ||
isMirroredCommandCodeTurnBump(existing, entry)
aggregateRelevantChange = aggregateRelevantChange || entrySortRelevantChange
sortRelevantChange = sortRelevantChange || entrySortRelevantChange
}
if (!changed) {
@ -726,7 +732,7 @@ function buildMirroredAgentStatusPatch(
return {
agentStatusByPaneKey: nextAgentStatusByPaneKey,
agentStatusEpoch: sortRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch,
agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch,
sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch
}
}
@ -1292,6 +1298,8 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn
a.stateStartedAt === b.stateStartedAt &&
a.agentType === b.agentType &&
a.paneKey === b.paneKey &&
a.worktreeId === b.worktreeId &&
a.tabId === b.tabId &&
a.terminalTitle === b.terminalTitle &&
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
@ -2443,6 +2451,23 @@ export function applyFreshWebSessionTabsSnapshots(
: applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId, now)
}
export function applyWebSessionTabsStorePatch(
buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>
): void {
let mirroredAgentStatusChanged = false
useAppStore.setState((state) => {
const patch = buildPatch(state)
mirroredAgentStatusChanged =
patch !== state && Object.prototype.hasOwnProperty.call(patch, 'agentStatusByPaneKey')
return patch
})
// Why: paired-web snapshots bypass setAgentStatus, so they must explicitly
// arm the same stale-boundary timer as local hook events.
if (mirroredAgentStatusChanged) {
useAppStore.getState().scheduleAgentStatusFreshness()
}
}
export function useWebSessionTabsSync(): void {
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
const runtimeSessionMirrorEnvironmentKey = useAppStore((state) =>
@ -2500,7 +2525,7 @@ export function useWebSessionTabsSync(): void {
console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload')
return
}
useAppStore.setState((state) =>
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId)
)
})
@ -2541,7 +2566,7 @@ export function useWebSessionTabsSync(): void {
acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree)
}
}
useAppStore.setState((state) =>
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId)
)
return
@ -2552,7 +2577,7 @@ export function useWebSessionTabsSync(): void {
if (replayed) {
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
}
useAppStore.setState((state) =>
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshot(state, event, environmentId)
)
},
@ -2657,7 +2682,7 @@ export function useWebSessionTabsSync(): void {
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
if (fresh) {
useAppStore.setState((state) =>
applyWebSessionTabsStorePatch((state) =>
applyWebSessionTabsSnapshot(state, event, environmentId)
)
}

View File

@ -104,6 +104,31 @@ describe('agent status freshness expiry', () => {
// No additional bump since the entry was removed before the timer fires
expect(store.getState().agentStatusEpoch).toBe(2)
})
it('arms freshness expiry for status rows written by an external mirror', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z'))
const store = createTestStore()
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
const now = Date.now()
store.setState({
agentStatusByPaneKey: {
[paneKey]: {
paneKey,
state: 'working',
prompt: 'Mirrored agent',
updatedAt: now,
stateStartedAt: now,
stateHistory: []
}
}
})
store.getState().scheduleAgentStatusFreshness()
vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS + 1)
expect(store.getState().agentStatusEpoch).toBe(1)
})
})
describe('agent status routing attribution', () => {
@ -598,6 +623,30 @@ describe('agent status tool + assistant fields', () => {
expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1)
})
it('bumps aggregate epochs when a same-state entry gains worktree attribution', () => {
vi.useFakeTimers()
const store = createTestStore()
store.getState().setAgentStatus('tab-1:1', { state: 'working', prompt: 'p' }, 'claude', {
updatedAt: 1_000,
stateStartedAt: 1_000
})
const firstEpoch = store.getState().agentStatusEpoch
const firstSortEpoch = store.getState().sortEpoch
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', prompt: 'p' },
'claude',
{ updatedAt: 2_000, stateStartedAt: 1_000 },
{ worktreeId: 'wt-1', tabId: 'tab-1' }
)
expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1)
expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1)
})
it('bumps the status epoch, not sort epoch, for same-state done updates', () => {
vi.useFakeTimers()
const store = createTestStore()

View File

@ -107,6 +107,8 @@ export type AgentStatusSlice = {
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>
/** Monotonic tick that advances when agent-status freshness boundaries pass. */
agentStatusEpoch: number
/** Arm the shared freshness timer after an external mirror writes live rows. */
scheduleAgentStatusFreshness: () => void
/** Retained "done" entries snapshots of agents that have disappeared from
* `agentStatusByPaneKey`. Keyed by paneKey so re-appearance of the same pane
@ -1009,6 +1011,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
agentLaunchConfigByPaneKey: {},
retentionSuppressedPaneKeys: {},
recentlyClosedAgentStatusTabIds: {},
scheduleAgentStatusFreshness: () => freshness.schedule(),
setRuntimeAgentOrchestrationByPaneKey: (entries) => {
const generatedTitleUpdates: AgentStatusEntry[] = []
@ -1419,6 +1422,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// stale.
const wasFresh =
!!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS)
// Why attribution is aggregate state: a late main-process stamp can
// change which workspace remains visible without changing agent state.
const attributionChanged =
existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId
// Why: main is authoritative on stateStartedAt and only advances it on a
// real turn boundary (state transition or a Command Code new turn). If the
// renderer-local `commandCodeNewTurn` misses it — e.g. a transcript-read
@ -1435,6 +1442,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
!existing ||
existing.state !== payload.state ||
!wasFresh ||
attributionChanged ||
commandCodeNewTurn ||
sameStateStateStartedAtChanged
const doneRetentionFieldsChanged =
@ -1452,7 +1460,8 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
entry.subagents !== existing.subagents ||
entry.providerSession !== existing.providerSession ||
entry.interrupted !== existing.interrupted)
const retentionRelevantChange = sortRelevantChange || doneRetentionFieldsChanged
const retentionRelevantChange =
sortRelevantChange || attributionChanged || doneRetentionFieldsChanged
// Why: a new status event means the agent is live again — lift any
// one-shot retention suppressor so the row can be retained normally
// on its next disappearance. setAgentStatus fires on every PTY status

View File

@ -1672,6 +1672,7 @@ describe('reconnectPersistedTerminals', () => {
tabsByWorktree: s.tabsByWorktree,
ptyIdsByTabId: s.ptyIdsByTabId,
browserTabsByWorktree: s.browserTabsByWorktree,
worktreeIdsWithLiveAgent: new Set(),
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
repoMap: new Map(s.repos.map((repo) => [repo.id, repo])),
@ -2020,6 +2021,7 @@ describe('reconnectPersistedTerminals', () => {
tabsByWorktree: s.tabsByWorktree,
ptyIdsByTabId: s.ptyIdsByTabId,
browserTabsByWorktree: s.browserTabsByWorktree,
worktreeIdsWithLiveAgent: new Set(),
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
repoMap: new Map(s.repos.map((repo) => [repo.id, repo])),

View File

@ -16,10 +16,7 @@ import {
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
import {
parkHiddenTabBehindDecoy,
waitForTabParked
} from './helpers/terminal-hidden-parking'
import { parkHiddenTabBehindDecoy, waitForTabParked } from './helpers/terminal-hidden-parking'
// Why: the parking wiring registers this handle (dev/exposeStore builds only)
// so tests can detect that hidden-view parking is compiled in and which delay