* 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:
parent
e98bfd67c1
commit
0b65d725c9
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 } : {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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+1–9 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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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']
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue