IJPL-233558 remove order

GitOrigin-RevId: 50d8ba849a0f1747bb4c6ac4a1e7e4163b9d9208
This commit is contained in:
Vladimir Krivosheev
2026-02-16 19:03:38 +00:00
committed by intellij-monorepo-bot
parent 4c2f36606e
commit 15ca8a4acf
20 changed files with 494 additions and 67 deletions
@@ -7,6 +7,8 @@
<!-- endregion -->
<extensions defaultExtensionNs="com.intellij">
<agent.workbench.sessionProviderBridge implementation="com.intellij.agent.workbench.claude.sessions.ClaudeAgentSessionProviderBridge"/>
<agent.workbench.sessionProviderBridge
id="agentWorkbenchClaude"
implementation="com.intellij.agent.workbench.claude.sessions.ClaudeAgentSessionProviderBridge"/>
</extensions>
</idea-plugin>
@@ -11,9 +11,6 @@ import com.intellij.agent.workbench.sessions.providers.AgentSessionSource
internal class ClaudeAgentSessionProviderBridge(
override val sessionSource: AgentSessionSource = ClaudeSessionSource(),
) : AgentSessionProviderBridge {
override val order: Int
get() = 10
override val provider: AgentSessionProvider
get() = AgentSessionProvider.CLAUDE
@@ -8,7 +8,9 @@
<!-- endregion -->
<extensions defaultExtensionNs="com.intellij">
<agent.workbench.sessionProviderBridge implementation="com.intellij.agent.workbench.codex.sessions.CodexAgentSessionProviderBridge"/>
<agent.workbench.sessionProviderBridge
id="agentWorkbenchCodex"
order="before agentWorkbenchClaude"
implementation="com.intellij.agent.workbench.codex.sessions.CodexAgentSessionProviderBridge"/>
</extensions>
</idea-plugin>
@@ -15,9 +15,6 @@ import com.intellij.openapi.components.service
internal class CodexAgentSessionProviderBridge(
override val sessionSource: AgentSessionSource = CodexSessionSource(),
) : AgentSessionProviderBridge {
override val order: Int
get() = 20
override val provider: AgentSessionProvider
get() = AgentSessionProvider.CODEX
@@ -50,6 +50,7 @@ data class AgentSessionThreadPreview(
@JvmField val id: String,
@JvmField val title: String,
@JvmField val updatedAt: Long,
val provider: AgentSessionProvider = AgentSessionProvider.CODEX,
)
@Immutable
@@ -66,6 +66,7 @@ internal class AgentSessionsService private constructor(
private val serviceScope: CoroutineScope,
private val sessionSourcesProvider: () -> List<AgentSessionSource>,
private val projectEntriesProvider: suspend (AgentSessionsService) -> List<ProjectEntry>,
private val treeUiState: SessionsTreeUiState,
subscribeToProjectLifecycle: Boolean,
) {
@Suppress("unused")
@@ -73,6 +74,7 @@ internal class AgentSessionsService private constructor(
serviceScope = serviceScope,
sessionSourcesProvider = AgentSessionProviderBridges::sessionSources,
projectEntriesProvider = { service -> service.collectProjects() },
treeUiState = service<AgentSessionsTreeUiStateService>(),
subscribeToProjectLifecycle = true,
)
@@ -80,11 +82,13 @@ internal class AgentSessionsService private constructor(
serviceScope: CoroutineScope,
sessionSourcesProvider: () -> List<AgentSessionSource>,
projectEntriesProvider: suspend () -> List<ProjectEntry>,
treeUiState: SessionsTreeUiState = InMemorySessionsTreeUiState(),
subscribeToProjectLifecycle: Boolean = false,
) : this(
serviceScope = serviceScope,
sessionSourcesProvider = sessionSourcesProvider,
projectEntriesProvider = { _ -> projectEntriesProvider() },
treeUiState = treeUiState,
subscribeToProjectLifecycle = subscribeToProjectLifecycle,
)
@@ -280,49 +284,82 @@ internal class AgentSessionsService private constructor(
}
try {
val entries = projectEntriesProvider(this@AgentSessionsService)
val currentProjectsByPath = mutableState.value.projects.associateBy { it.path }
val currentState = mutableState.value
val currentProjectsByPath = currentState.projects.associateBy { normalizePath(it.path) }
val openPaths = entries.flatMap { entry ->
buildList {
if (entry.project != null) add(normalizePath(entry.path))
entry.worktreeEntries.filter { it.project != null }.forEach { add(normalizePath(it.path)) }
}
}
treeUiState.retainOpenProjectThreadPreviews(openPaths.toSet())
val knownPaths = entries.flatMap { entry ->
buildList {
add(normalizePath(entry.path))
entry.worktreeEntries.forEach { add(normalizePath(it.path)) }
}
}
val initialVisibleThreadCounts = buildInitialVisibleThreadCounts(
knownPaths = knownPaths,
currentVisibleThreadCounts = currentState.visibleThreadCounts,
)
val initialProjects = entries.map { entry ->
val existing = currentProjectsByPath[entry.path]
val normalizedEntryPath = normalizePath(entry.path)
val existing = currentProjectsByPath[normalizedEntryPath]
val cachedPreviews = if (entry.project != null) {
treeUiState.getOpenProjectThreadPreviews(normalizedEntryPath)
}
else {
null
}
val cachedThreads = cachedPreviews.orEmpty().toCachedSessionThreads()
AgentProjectSessions(
path = entry.path,
path = normalizedEntryPath,
name = entry.name,
branch = entry.branch ?: existing?.branch,
isOpen = entry.project != null,
isLoading = entry.project != null,
hasLoaded = existing?.hasLoaded ?: false,
hasLoaded = existing?.hasLoaded ?: (cachedPreviews != null),
hasUnknownThreadCount = existing?.hasUnknownThreadCount ?: false,
threads = existing?.threads ?: emptyList(),
threads = existing?.threads ?: cachedThreads,
errorMessage = existing?.errorMessage,
providerWarnings = existing?.providerWarnings ?: emptyList(),
worktrees = entry.worktreeEntries.map { wt ->
val existingWt = existing?.worktrees?.firstOrNull { it.path == wt.path }
val normalizedWorktreePath = normalizePath(wt.path)
val existingWt = existing?.worktrees?.firstOrNull { normalizePath(it.path) == normalizedWorktreePath }
val cachedWorktreePreviews = if (wt.project != null) {
treeUiState.getOpenProjectThreadPreviews(normalizedWorktreePath)
}
else {
null
}
val cachedWorktreeThreads = cachedWorktreePreviews.orEmpty().toCachedSessionThreads()
val hasExistingData = existingWt != null && existingWt.threads.isNotEmpty()
AgentWorktree(
path = wt.path,
path = normalizedWorktreePath,
name = wt.name,
branch = wt.branch,
isOpen = wt.project != null,
isLoading = wt.project != null && hasExistingData,
hasLoaded = existingWt?.hasLoaded ?: false,
isLoading = wt.project != null && (hasExistingData || cachedWorktreePreviews != null),
hasLoaded = existingWt?.hasLoaded ?: (cachedWorktreePreviews != null),
hasUnknownThreadCount = existingWt?.hasUnknownThreadCount ?: false,
threads = existingWt?.threads ?: emptyList(),
threads = existingWt?.threads ?: cachedWorktreeThreads,
errorMessage = existingWt?.errorMessage,
providerWarnings = existingWt?.providerWarnings ?: emptyList(),
)
},
)
}
mutableState.update { it.copy(projects = initialProjects, lastUpdatedAt = System.currentTimeMillis()) }
// Collect all open paths for prefetching (batch Codex calls)
val sessionSources = sessionSourcesProvider()
val openPaths = entries.flatMap { entry ->
buildList {
if (entry.project != null) add(entry.path)
entry.worktreeEntries.filter { it.project != null }.forEach { add(it.path) }
}
mutableState.update {
it.copy(
projects = initialProjects,
visibleThreadCounts = initialVisibleThreadCounts,
lastUpdatedAt = System.currentTimeMillis(),
)
}
val sessionSources = sessionSourcesProvider()
// Prefetch from all sources in parallel
val prefetchedByProvider = coroutineScope {
sessionSources.map { source ->
@@ -342,8 +379,9 @@ internal class AgentSessionsService private constructor(
coroutineScope {
for (entry in entries) {
launch {
val normalizedEntryPath = normalizePath(entry.path)
if (entry.project == null) {
updateProject(entry.path) { it.copy(isLoading = false) }
updateProject(normalizedEntryPath) { it.copy(isLoading = false) }
return@launch
}
val sourceResults = java.util.concurrent.CopyOnWriteArrayList<AgentSessionSourceLoadResult>()
@@ -351,9 +389,9 @@ internal class AgentSessionsService private constructor(
for (source in sessionSources) {
launch {
val sourceResult = try {
val prefetched = prefetchedByProvider[source.provider]?.get(entry.path)
val prefetched = prefetchedByProvider[source.provider]?.get(normalizedEntryPath)
val threads = prefetched
?: source.listThreadsFromOpenProject(path = entry.path, project = entry.project)
?: source.listThreadsFromOpenProject(path = normalizedEntryPath, project = entry.project)
AgentSessionSourceLoadResult(
provider = source.provider,
result = Result.success(threads),
@@ -376,7 +414,7 @@ internal class AgentSessionsService private constructor(
resolveWarningMessage = ::resolveProviderWarningMessage,
)
val anySuccess = sourceResults.any { it.result.isSuccess }
updateProject(entry.path) { project ->
updateProject(normalizedEntryPath) { project ->
project.copy(
threads = partial.threads,
providerWarnings = partial.providerWarnings,
@@ -392,7 +430,7 @@ internal class AgentSessionsService private constructor(
resolveErrorMessage = ::resolveErrorMessage,
resolveWarningMessage = ::resolveProviderWarningMessage,
)
updateProject(entry.path) { project ->
updateProject(normalizedEntryPath) { project ->
project.copy(isLoading = false,
hasLoaded = true,
hasUnknownThreadCount = finalResult.hasUnknownThreadCount,
@@ -400,11 +438,16 @@ internal class AgentSessionsService private constructor(
errorMessage = finalResult.errorMessage,
providerWarnings = finalResult.providerWarnings)
}
if (finalResult.errorMessage == null) {
treeUiState.setOpenProjectThreadPreviews(normalizedEntryPath, finalResult.threads.toThreadPreviews())
}
}
for (wt in entry.worktreeEntries) {
launch {
val normalizedEntryPath = normalizePath(entry.path)
val normalizedWorktreePath = normalizePath(wt.path)
if (wt.project == null) {
updateWorktree(entry.path, wt.path) { it.copy(isLoading = false) }
updateWorktree(normalizedEntryPath, normalizedWorktreePath) { it.copy(isLoading = false) }
return@launch
}
val sourceResults = java.util.concurrent.CopyOnWriteArrayList<AgentSessionSourceLoadResult>()
@@ -412,9 +455,9 @@ internal class AgentSessionsService private constructor(
for (source in sessionSources) {
launch {
val sourceResult = try {
val prefetched = prefetchedByProvider[source.provider]?.get(wt.path)
val prefetched = prefetchedByProvider[source.provider]?.get(normalizedWorktreePath)
val threads = prefetched
?: source.listThreadsFromOpenProject(path = wt.path, project = wt.project)
?: source.listThreadsFromOpenProject(path = normalizedWorktreePath, project = wt.project)
AgentSessionSourceLoadResult(
provider = source.provider,
result = Result.success(threads),
@@ -436,7 +479,7 @@ internal class AgentSessionsService private constructor(
resolveWarningMessage = ::resolveProviderWarningMessage,
)
val anySuccess = sourceResults.any { it.result.isSuccess }
updateWorktree(entry.path, wt.path) { worktree ->
updateWorktree(normalizedEntryPath, normalizedWorktreePath) { worktree ->
worktree.copy(
threads = partial.threads,
providerWarnings = partial.providerWarnings,
@@ -451,7 +494,7 @@ internal class AgentSessionsService private constructor(
resolveErrorMessage = ::resolveErrorMessage,
resolveWarningMessage = ::resolveProviderWarningMessage,
)
updateWorktree(entry.path, wt.path) { worktree ->
updateWorktree(normalizedEntryPath, normalizedWorktreePath) { worktree ->
worktree.copy(isLoading = false,
hasLoaded = true,
hasUnknownThreadCount = finalResult.hasUnknownThreadCount,
@@ -459,6 +502,9 @@ internal class AgentSessionsService private constructor(
errorMessage = finalResult.errorMessage,
providerWarnings = finalResult.providerWarnings)
}
if (finalResult.errorMessage == null) {
treeUiState.setOpenProjectThreadPreviews(normalizedWorktreePath, finalResult.threads.toThreadPreviews())
}
}
}
}
@@ -510,14 +556,22 @@ internal class AgentSessionsService private constructor(
}
fun showMoreThreads(path: String) {
val normalizedPath = normalizePath(path)
var deltaToPersist = 0
mutableState.update { state ->
val current = state.visibleThreadCounts[path] ?: DEFAULT_VISIBLE_THREAD_COUNT
state.copy(visibleThreadCounts = state.visibleThreadCounts + (path to (current + DEFAULT_VISIBLE_THREAD_COUNT)))
val current = state.visibleThreadCounts[normalizedPath] ?: treeUiState.getVisibleThreadCount(normalizedPath)
val nextVisible = current + DEFAULT_VISIBLE_THREAD_COUNT
deltaToPersist = nextVisible - current
state.copy(visibleThreadCounts = state.visibleThreadCounts + (normalizedPath to nextVisible))
}
if (deltaToPersist > 0) {
treeUiState.incrementVisibleThreadCount(normalizedPath, deltaToPersist)
}
}
fun ensureThreadVisible(path: String, provider: AgentSessionProvider, threadId: String) {
val normalizedPath = normalizePath(path)
var deltaToPersist = 0
mutableState.update { state ->
val threadIndex = findThreadIndex(
projects = state.projects,
@@ -525,7 +579,7 @@ internal class AgentSessionsService private constructor(
provider = provider,
threadId = threadId,
) ?: return@update state
val currentVisible = state.visibleThreadCounts[normalizedPath] ?: DEFAULT_VISIBLE_THREAD_COUNT
val currentVisible = state.visibleThreadCounts[normalizedPath] ?: treeUiState.getVisibleThreadCount(normalizedPath)
if (threadIndex < currentVisible) {
return@update state
}
@@ -534,8 +588,12 @@ internal class AgentSessionsService private constructor(
while (nextVisible < minVisible) {
nextVisible += DEFAULT_VISIBLE_THREAD_COUNT
}
deltaToPersist = nextVisible - currentVisible
state.copy(visibleThreadCounts = state.visibleThreadCounts + (normalizedPath to nextVisible))
}
if (deltaToPersist > 0) {
treeUiState.incrementVisibleThreadCount(normalizedPath, deltaToPersist)
}
}
fun openChatThread(path: String, thread: AgentSessionThread, currentProject: Project? = null) {
@@ -1396,6 +1454,51 @@ internal class AgentSessionsService private constructor(
return null
}
private fun buildInitialVisibleThreadCounts(
knownPaths: List<String>,
currentVisibleThreadCounts: Map<String, Int>,
): Map<String, Int> {
val normalizedKnownPaths = knownPaths.mapTo(LinkedHashSet()) { normalizePath(it) }
val visibleThreadCounts = LinkedHashMap<String, Int>()
currentVisibleThreadCounts.forEach { (path, count) ->
val normalized = normalizePath(path)
if (normalized in normalizedKnownPaths && count > DEFAULT_VISIBLE_THREAD_COUNT) {
visibleThreadCounts[normalized] = count
}
}
for (path in normalizedKnownPaths) {
if (path in visibleThreadCounts) continue
val persisted = treeUiState.getVisibleThreadCount(path)
if (persisted > DEFAULT_VISIBLE_THREAD_COUNT) {
visibleThreadCounts[path] = persisted
}
}
return visibleThreadCounts
}
private fun List<AgentSessionThreadPreview>.toCachedSessionThreads(): List<AgentSessionThread> {
return map { preview ->
AgentSessionThread(
id = preview.id,
title = preview.title,
updatedAt = preview.updatedAt,
archived = false,
provider = preview.provider,
)
}
}
private fun List<AgentSessionThread>.toThreadPreviews(): List<AgentSessionThreadPreview> {
return map { thread ->
AgentSessionThreadPreview(
id = thread.id,
title = thread.title,
updatedAt = thread.updatedAt,
provider = thread.provider,
)
}
}
private data class ProviderRefreshOutcome(
val threads: List<AgentSessionThread>? = null,
val warningMessage: String? = null,
@@ -84,6 +84,7 @@ internal fun agentSessionsToolWindow(currentProject: Project) {
onOpenThread = { path, thread -> service.openChatThread(path, thread, currentProject) },
onOpenSubAgent = { path, thread, subAgent -> service.openChatSubAgent(path, thread, subAgent, currentProject) },
onCreateSession = { path, provider, mode -> service.createNewSession(path, provider, mode, currentProject) },
treeUiState = uiStateService,
lastUsedProvider = lastUsedProvider,
visibleProjectCount = state.visibleProjectCount,
onShowMoreProjects = { service.showMoreProjects() },
@@ -111,6 +112,7 @@ internal fun agentSessionsToolWindowContent(
onOpenThread: (String, AgentSessionThread) -> Unit = { _, _ -> },
onOpenSubAgent: (String, AgentSessionThread, AgentSubAgent) -> Unit = { _, _, _ -> },
onCreateSession: (String, AgentSessionProvider, AgentSessionLaunchMode) -> Unit = { _, _, _ -> },
treeUiState: SessionsTreeUiState? = null,
lastUsedProvider: AgentSessionProvider? = null,
nowProvider: () -> Long = { System.currentTimeMillis() },
visibleProjectCount: Int = Int.MAX_VALUE,
@@ -122,6 +124,7 @@ internal fun agentSessionsToolWindowContent(
onEnableClaudeQuotaWidget: () -> Unit = {},
onDismissClaudeQuotaHint: () -> Unit = {},
) {
val effectiveTreeUiState = treeUiState ?: remember { InMemorySessionsTreeUiState() }
Column(
modifier = Modifier
.fillMaxSize()
@@ -146,6 +149,7 @@ internal fun agentSessionsToolWindowContent(
onOpenThread = onOpenThread,
onOpenSubAgent = onOpenSubAgent,
onCreateSession = onCreateSession,
treeUiState = effectiveTreeUiState,
lastUsedProvider = lastUsedProvider,
nowProvider = nowProvider,
visibleProjectCount = visibleProjectCount,
@@ -158,6 +158,9 @@ internal class AgentSessionsTreeUiStateService
id = preview.id,
title = preview.title,
updatedAt = preview.updatedAt,
provider = preview.provider
?.let(AgentSessionProvider::fromOrNull)
?: AgentSessionProvider.CODEX,
)
}
}
@@ -165,7 +168,14 @@ internal class AgentSessionsTreeUiStateService
override fun setOpenProjectThreadPreviews(path: String, threads: List<AgentSessionThreadPreview>): Boolean {
val normalizedPath = normalizeSessionsProjectPath(path)
val normalizedPreviews = normalizeOpenProjectThreadPreviewList(threads)
.map { thread -> ThreadPreviewState(id = thread.id, title = thread.title, updatedAt = thread.updatedAt) }
.map { thread ->
ThreadPreviewState(
id = thread.id,
title = thread.title,
updatedAt = thread.updatedAt,
provider = thread.provider.value,
)
}
val current = state.openProjectThreadPreviewsByProject[normalizedPath]
if (current == normalizedPreviews) {
return false
@@ -278,6 +288,7 @@ internal class AgentSessionsTreeUiStateService
@JvmField val id: String,
@JvmField val title: String,
@JvmField val updatedAt: Long,
@JvmField val provider: String? = null,
)
}
@@ -32,6 +32,7 @@ internal fun sessionTree(
onOpenThread: (String, AgentSessionThread) -> Unit,
onOpenSubAgent: (String, AgentSessionThread, AgentSubAgent) -> Unit,
onCreateSession: (String, AgentSessionProvider, AgentSessionLaunchMode) -> Unit = { _, _, _ -> },
treeUiState: SessionsTreeUiState,
lastUsedProvider: AgentSessionProvider? = null,
nowProvider: () -> Long,
visibleProjectCount: Int = Int.MAX_VALUE,
@@ -41,12 +42,17 @@ internal fun sessionTree(
selectedTreeId: SessionTreeId? = null,
) {
val stateHolder = rememberSessionTreeStateHolder(
onProjectExpanded = onProjectExpanded,
onProjectCollapsed = {},
onProjectExpanded = { path ->
treeUiState.setProjectCollapsed(path, collapsed = false)
onProjectExpanded(path)
},
onProjectCollapsed = { path ->
treeUiState.setProjectCollapsed(path, collapsed = true)
},
onWorktreeExpanded = onWorktreeExpanded,
)
val treeState = stateHolder.treeState
val autoOpenNodes = remember(projects, visibleProjectCount) {
val autoOpenNodes = remember(projects, visibleProjectCount, treeUiState) {
projects.take(visibleProjectCount)
.filter {
it.isOpen ||
@@ -54,6 +60,7 @@ internal fun sessionTree(
it.providerWarnings.isNotEmpty() ||
it.worktrees.any { wt -> wt.isOpen }
}
.filterNot { treeUiState.isProjectCollapsed(it.path) }
.map { SessionTreeId.Project(it.path) }
}
LaunchedEffect(autoOpenNodes) {
@@ -5,9 +5,6 @@ import com.intellij.agent.workbench.sessions.AgentSessionLaunchMode
import com.intellij.agent.workbench.sessions.AgentSessionProvider
interface AgentSessionProviderBridge {
val order: Int
get() = 0
val provider: AgentSessionProvider
val displayNameKey: String
val newSessionLabelKey: String
@@ -50,11 +50,10 @@ private fun buildAgentSessionProviderBridgeSnapshot(bridges: Iterable<AgentSessi
uniqueBridges += bridge
}
}
val orderedBridges = uniqueBridges.sortedBy { it.order }
return AgentSessionProviderBridgeSnapshot(
bridgesByProvider = bridgesByProvider,
orderedBridges = orderedBridges,
sessionSources = orderedBridges.map { it.sessionSource },
orderedBridges = uniqueBridges,
sessionSources = uniqueBridges.map { it.sessionSource },
)
}
@@ -7,6 +7,7 @@ import com.intellij.agent.workbench.sessions.providers.AgentSessionProviderBridg
import com.intellij.agent.workbench.sessions.providers.AgentSessionSource
import com.intellij.agent.workbench.sessions.providers.InMemoryAgentSessionProviderRegistry
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.extensions.LoadingOrder
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.junit5.TestApplication
@@ -72,16 +73,16 @@ class AgentSessionProviderBridgesTest {
}
@Test
fun allBridgesAreSortedByOrder() {
fun allBridgesFollowExtensionOrder() {
val disposable = Disposer.newDisposable()
try {
val highOrder = TestAgentSessionProviderBridge(provider = AgentSessionProvider.from("aaa"), sourceId = "high", order = 200)
val lowOrder = TestAgentSessionProviderBridge(provider = AgentSessionProvider.from("bbb"), sourceId = "low", order = -5)
extensionPoint.point.registerExtension(highOrder, disposable)
extensionPoint.point.registerExtension(lowOrder, disposable)
val lastBridge = TestAgentSessionProviderBridge(provider = AgentSessionProvider.from("aaa"), sourceId = "last")
val firstBridge = TestAgentSessionProviderBridge(provider = AgentSessionProvider.from("bbb"), sourceId = "first")
extensionPoint.point.registerExtension(lastBridge, LoadingOrder.LAST, disposable)
extensionPoint.point.registerExtension(firstBridge, LoadingOrder.FIRST, disposable)
val orderedIds = AgentSessionProviderBridges.allBridges().map { it.provider.value }
assertThat(orderedIds.indexOf(lowOrder.provider.value)).isLessThan(orderedIds.indexOf(highOrder.provider.value))
assertThat(orderedIds.indexOf(firstBridge.provider.value)).isLessThan(orderedIds.indexOf(lastBridge.provider.value))
}
finally {
Disposer.dispose(disposable)
@@ -90,9 +91,9 @@ class AgentSessionProviderBridgesTest {
@Test
fun registryCanBeOverriddenForIsolatedTests() {
val codexBridge = TestAgentSessionProviderBridge(provider = AgentSessionProvider.CODEX, sourceId = "override-codex", order = 50)
val claudeBridge = TestAgentSessionProviderBridge(provider = AgentSessionProvider.CLAUDE, sourceId = "override-claude", order = -50)
val overrideRegistry = InMemoryAgentSessionProviderRegistry(listOf(codexBridge, claudeBridge))
val codexBridge = TestAgentSessionProviderBridge(provider = AgentSessionProvider.CODEX, sourceId = "override-codex")
val claudeBridge = TestAgentSessionProviderBridge(provider = AgentSessionProvider.CLAUDE, sourceId = "override-claude")
val overrideRegistry = InMemoryAgentSessionProviderRegistry(listOf(claudeBridge, codexBridge))
val baselineClaude = AgentSessionProviderBridges.find(AgentSessionProvider.CLAUDE)
AgentSessionProviderBridges.withRegistryForTest(overrideRegistry) {
@@ -108,7 +109,6 @@ class AgentSessionProviderBridgesTest {
private class TestAgentSessionProviderBridge(
override val provider: AgentSessionProvider,
sourceId: String,
override val order: Int = 0,
) : AgentSessionProviderBridge {
override val sessionSource: AgentSessionSource = TestAgentSessionSource(provider, sourceId)
@@ -46,6 +46,7 @@ internal fun thread(id: String, updatedAt: Long, provider: AgentSessionProvider)
internal suspend fun withService(
sessionSourcesProvider: () -> List<AgentSessionSource>,
projectEntriesProvider: suspend () -> List<AgentSessionsService.ProjectEntry>,
treeUiState: SessionsTreeUiState = InMemorySessionsTreeUiState(),
action: suspend (AgentSessionsService) -> Unit,
) {
@Suppress("RAW_SCOPE_CREATION")
@@ -55,6 +56,7 @@ internal suspend fun withService(
serviceScope = scope,
sessionSourcesProvider = sessionSourcesProvider,
projectEntriesProvider = projectEntriesProvider,
treeUiState = treeUiState,
subscribeToProjectLifecycle = false,
)
action(service)
@@ -12,6 +12,7 @@ import java.util.concurrent.atomic.AtomicInteger
class AgentSessionsServiceOnDemandIntegrationTest {
@Test
fun ensureThreadVisibleExpandsProjectVisibleCountForHiddenThread() = runBlocking {
val treeUiState = InMemorySessionsTreeUiState()
withService(
sessionSourcesProvider = {
listOf(
@@ -38,6 +39,7 @@ class AgentSessionsServiceOnDemandIntegrationTest {
projectEntriesProvider = {
listOf(closedProjectEntry(PROJECT_PATH, "Project A"))
},
treeUiState = treeUiState,
) { service ->
service.refresh()
waitForCondition {
@@ -53,6 +55,39 @@ class AgentSessionsServiceOnDemandIntegrationTest {
assertThat(service.state.value.visibleThreadCounts[PROJECT_PATH])
.isEqualTo(DEFAULT_VISIBLE_THREAD_COUNT + DEFAULT_VISIBLE_THREAD_COUNT)
assertThat(treeUiState.getVisibleThreadCount(PROJECT_PATH))
.isEqualTo(DEFAULT_VISIBLE_THREAD_COUNT + DEFAULT_VISIBLE_THREAD_COUNT)
}
}
@Test
fun showMoreThreadsPersistsVisibleCountToUiState() = runBlocking {
val treeUiState = InMemorySessionsTreeUiState()
withService(
sessionSourcesProvider = {
listOf(
ScriptedSessionSource(
provider = AgentSessionProvider.CODEX,
canReportExactThreadCount = true,
),
)
},
projectEntriesProvider = {
listOf(closedProjectEntry(PROJECT_PATH, "Project A"))
},
treeUiState = treeUiState,
) { service ->
service.refresh()
waitForCondition {
service.state.value.projects.any { it.path == PROJECT_PATH }
}
service.showMoreThreads(PROJECT_PATH)
assertThat(service.state.value.visibleThreadCounts[PROJECT_PATH])
.isEqualTo(DEFAULT_VISIBLE_THREAD_COUNT + DEFAULT_VISIBLE_THREAD_COUNT)
assertThat(treeUiState.getVisibleThreadCount(PROJECT_PATH))
.isEqualTo(DEFAULT_VISIBLE_THREAD_COUNT + DEFAULT_VISIBLE_THREAD_COUNT)
}
}
@@ -2,6 +2,7 @@
package com.intellij.agent.workbench.sessions
import com.intellij.testFramework.junit5.TestApplication
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
@@ -9,6 +10,92 @@ import org.junit.jupiter.api.Test
@TestApplication
class AgentSessionsServiceRefreshIntegrationTest {
@Test
fun refreshShowsCachedOpenProjectThreadsBeforeProviderLoadCompletes() = runBlocking {
val started = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
val treeUiState = InMemorySessionsTreeUiState()
treeUiState.setOpenProjectThreadPreviews(
PROJECT_PATH,
listOf(
AgentSessionThreadPreview(
id = "cached-1",
title = "Cached",
updatedAt = 100,
provider = AgentSessionProvider.CLAUDE,
)
),
)
withService(
sessionSourcesProvider = {
listOf(
ScriptedSessionSource(
provider = AgentSessionProvider.CLAUDE,
listFromOpenProject = { path, _ ->
if (path != PROJECT_PATH) {
emptyList()
}
else {
started.complete(Unit)
release.await()
listOf(thread(id = "claude-1", updatedAt = 200, provider = AgentSessionProvider.CLAUDE))
}
},
)
)
},
projectEntriesProvider = {
listOf(openProjectEntry(PROJECT_PATH, "Project A"))
},
treeUiState = treeUiState,
) { service ->
service.refresh()
started.await()
waitForCondition {
val project = service.state.value.projects.firstOrNull { it.path == PROJECT_PATH } ?: return@waitForCondition false
project.hasLoaded && project.threads.map { it.id } == listOf("cached-1")
}
release.complete(Unit)
waitForCondition {
service.state.value.projects.firstOrNull { it.path == PROJECT_PATH }?.threads?.map { it.id } == listOf("claude-1")
}
assertThat(treeUiState.getOpenProjectThreadPreviews(PROJECT_PATH).orEmpty().map { it.id })
.containsExactly("claude-1")
}
}
@Test
fun refreshRestoresPersistedVisibleThreadCountForKnownPath() = runBlocking {
val treeUiState = InMemorySessionsTreeUiState()
treeUiState.incrementVisibleThreadCount(PROJECT_PATH, delta = 6)
withService(
sessionSourcesProvider = {
listOf(
ScriptedSessionSource(
provider = AgentSessionProvider.CODEX,
)
)
},
projectEntriesProvider = {
listOf(closedProjectEntry(PROJECT_PATH, "Project A"))
},
treeUiState = treeUiState,
) { service ->
service.refresh()
waitForCondition {
service.state.value.projects.any { it.path == PROJECT_PATH }
}
assertThat(service.state.value.visibleThreadCounts[PROJECT_PATH])
.isEqualTo(DEFAULT_VISIBLE_THREAD_COUNT + 6)
}
}
@Test
fun refreshMergesMixedProviderThreadsAndMarksUnknownCount() = runBlocking {
withService(
@@ -398,6 +398,99 @@ class AgentSessionsToolWindowTest {
.assertIsDisplayed()
}
@Test
fun collapsedProjectInUiStateIsNotAutoExpandedAndExpandingClearsCollapsedFlag() {
val projectPath = "/work/project-a"
val treeUiState = InMemorySessionsTreeUiState()
treeUiState.setProjectCollapsed(projectPath, collapsed = true)
val projects = listOf(
AgentProjectSessions(
path = projectPath,
name = "Project A",
isOpen = true,
hasLoaded = true,
),
)
composeRule.setContentWithTheme {
agentSessionsToolWindowContent(
state = AgentSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
treeUiState = treeUiState,
)
}
composeRule.onNodeWithText("Project A").assertIsDisplayed()
composeRule.onAllNodesWithText(AgentSessionsBundle.message("toolwindow.empty.project"))
.assertCountEquals(0)
composeRule.onNodeWithText("Project A")
.assertIsDisplayed()
.performMouseInput { doubleClick() }
composeRule.onNodeWithText(AgentSessionsBundle.message("toolwindow.empty.project"))
.assertIsDisplayed()
composeRule.runOnIdle {
assertThat(treeUiState.isProjectCollapsed(projectPath)).isFalse()
}
}
@Test
fun collapsedProjectInPersistentUiStateRemainsCollapsedAfterContentRefresh() {
val projectPath = "/work/project-a"
val treeUiState = AgentSessionsTreeUiStateService()
val projects = listOf(
AgentProjectSessions(
path = projectPath,
name = "Project A",
isOpen = true,
hasLoaded = true,
),
)
composeRule.setContentWithTheme {
agentSessionsToolWindowContent(
state = AgentSessionsState(projects = projects, lastUpdatedAt = 1L),
onRefresh = {},
onOpenProject = {},
treeUiState = treeUiState,
)
}
composeRule.onNodeWithText(AgentSessionsBundle.message("toolwindow.empty.project"))
.assertIsDisplayed()
composeRule.onNodeWithText("Project A")
.assertIsDisplayed()
.performMouseInput { doubleClick() }
composeRule.onAllNodesWithText(AgentSessionsBundle.message("toolwindow.empty.project"))
.assertCountEquals(0)
composeRule.runOnIdle {
assertThat(treeUiState.isProjectCollapsed(projectPath)).isTrue()
}
composeRule.setContentWithTheme {
agentSessionsToolWindowContent(
state = AgentSessionsState(projects = projects, lastUpdatedAt = 2L),
onRefresh = {},
onOpenProject = {},
treeUiState = treeUiState,
)
}
composeRule.onNodeWithText("Project A").assertIsDisplayed()
composeRule.onAllNodesWithText(AgentSessionsBundle.message("toolwindow.empty.project"))
.assertCountEquals(0)
composeRule.onNodeWithText("Project A")
.assertIsDisplayed()
.performMouseInput { doubleClick() }
composeRule.onNodeWithText(AgentSessionsBundle.message("toolwindow.empty.project"))
.assertIsDisplayed()
composeRule.runOnIdle {
assertThat(treeUiState.isProjectCollapsed(projectPath)).isFalse()
}
}
@Test
fun expandingLoadedEmptyProjectShowsEmptyChildRow() {
val now = 1_700_000_000_000L
@@ -61,6 +61,59 @@ class AgentSessionsTreeUiStateServiceTest {
assertNull(uiState.getOpenProjectThreadPreviews("/work/project-a"))
}
@Test
fun openProjectThreadPreviewCachePreservesProvider() {
val uiState = AgentSessionsTreeUiStateService()
val threads = listOf(
AgentSessionThreadPreview(
id = "claude-thread",
title = "Claude Thread",
updatedAt = 5L,
provider = AgentSessionProvider.CLAUDE,
)
)
assertTrue(uiState.setOpenProjectThreadPreviews("/work/project-a", threads))
assertEquals(
AgentSessionProvider.CLAUDE,
uiState.getOpenProjectThreadPreviews("/work/project-a")?.single()?.provider,
)
}
@Test
fun treeStateFieldsSurviveServiceStateRoundTrip() {
val original = AgentSessionsTreeUiStateService()
val path = "/work/project-a/"
original.setProjectCollapsed(path, collapsed = true)
original.incrementVisibleThreadCount(path, delta = 4)
original.setOpenProjectThreadPreviews(
path,
listOf(
AgentSessionThreadPreview(
id = "claude-thread",
title = "Claude Thread",
updatedAt = 20,
provider = AgentSessionProvider.CLAUDE,
),
AgentSessionThreadPreview(
id = "codex-thread",
title = "Codex Thread",
updatedAt = 10,
provider = AgentSessionProvider.CODEX,
),
)
)
val reloaded = AgentSessionsTreeUiStateService()
reloaded.loadState(original.state)
assertTrue(reloaded.isProjectCollapsed("/work/project-a"))
assertEquals(DEFAULT_VISIBLE_THREAD_COUNT + 4, reloaded.getVisibleThreadCount("/work/project-a"))
val previews = reloaded.getOpenProjectThreadPreviews("/work/project-a").orEmpty()
assertEquals(listOf("claude-thread", "codex-thread"), previews.map { it.id })
assertEquals(listOf(AgentSessionProvider.CLAUDE, AgentSessionProvider.CODEX), previews.map { it.provider })
}
@Test
fun lastUsedProviderDefaultsToNull() {
val uiState = AgentSessionsTreeUiStateService()
@@ -2,6 +2,7 @@
name: Agent Threads Testing
description: Coverage requirements for provider aggregation, tree rendering, and backend contracts in Agent Threads.
targets:
- ../sessions/src/AgentSessionsTreeUiStateService.kt
- ../codex/sessions/testSrc/CodexRolloutSessionBackendTest.kt
- ../codex/sessions/testSrc/CodexSessionBackendSelectorTest.kt
- ../sessions/testSrc/AgentSessionLoadAggregationTest.kt
@@ -10,6 +11,7 @@ targets:
- ../sessions/testSrc/AgentSessionsServiceConcurrencyIntegrationTest.kt
- ../sessions/testSrc/AgentSessionsServiceIntegrationTestSupport.kt
- ../sessions/testSrc/AgentSessionsToolWindowTest.kt
- ../sessions/testSrc/AgentSessionsTreeUiStateServiceTest.kt
- ../sessions/testSrc/CodexSessionsPagingLogicTest.kt
- ../sessions/testSrc/CodexAppServerClientTest.kt
- ../sessions/testSrc/CodexAppServerClientTestSupport.kt
@@ -19,7 +21,7 @@ targets:
# Agent Threads Testing
Status: Draft
Date: 2026-02-11
Date: 2026-02-16
## Summary
Define required test coverage for the multi-provider Agent Threads stack: source aggregation, service behavior, tree/UI rendering, and Codex backend protocol compatibility.
@@ -43,10 +45,14 @@ Define required test coverage for the multi-provider Agent Threads stack: source
- Service integration tests must cover:
- mixed-provider refresh merge,
- provider warning and blocking error paths,
- unknown-count behavior when unknown provider fails/succeeds.
- unknown-count behavior when unknown provider fails/succeeds,
- cached preview rows rendered before open-path provider load completes,
- persisted visible thread count restoration during refresh bootstrap.
- On-demand integration tests must cover:
- project request deduplication,
- worktree request deduplication with refresh interaction.
- worktree request deduplication with refresh interaction,
- `showMoreThreads` visible-count persistence,
- `ensureThreadVisible` visible-count persistence.
- Concurrency integration tests must verify refresh mutex deduplicates overlapping refresh calls.
- Codex rollout backend tests must cover rollout parsing/activity behavior as the default thread-discovery path.
- Codex backend selector tests must verify rollout default behavior and explicit app-server override behavior.
@@ -54,7 +60,13 @@ Define required test coverage for the multi-provider Agent Threads stack: source
- provider warning rendering,
- error row precedence over warnings,
- `More…` rendering for unknown count,
- `More (N)` rendering for exact count.
- `More (N)` rendering for exact count,
- persisted collapsed state blocking default auto-expand,
- collapsed-state persistence across content refresh/recreation when persistent tree UI state is used.
- Tree UI state service tests must cover:
- collapsed/visible-count/open-preview state round-trip,
- preview provider identity persistence,
- backward-compatible provider default for legacy preview entries with missing provider value.
- Codex compatibility tests must cover cursor-loop/no-progress guard behavior in `seedInitialVisibleThreads`.
- Codex app-server contract tests must run against mock backend always and real backend when available.
@@ -63,6 +75,7 @@ Define required test coverage for the multi-provider Agent Threads stack: source
[@test] ../sessions/testSrc/AgentSessionsServiceOnDemandIntegrationTest.kt
[@test] ../sessions/testSrc/AgentSessionsServiceConcurrencyIntegrationTest.kt
[@test] ../sessions/testSrc/AgentSessionsToolWindowTest.kt
[@test] ../sessions/testSrc/AgentSessionsTreeUiStateServiceTest.kt
[@test] ../sessions/testSrc/CodexSessionsPagingLogicTest.kt
[@test] ../sessions/testSrc/CodexAppServerClientTest.kt
@@ -8,12 +8,13 @@ targets:
- ../sessions/resources/messages/AgentSessionsBundle.properties
- ../sessions/testSrc/AgentSessionsToolWindowTest.kt
- ../sessions/testSrc/AgentSessionsServiceRefreshIntegrationTest.kt
- ../sessions/testSrc/AgentSessionsServiceOnDemandIntegrationTest.kt
---
# Agent Threads Visibility and More Row
Status: Draft
Date: 2026-02-11
Date: 2026-02-16
## Summary
Define one consistent visibility model for project/worktree thread rows so empty state, warning/error rows, and `More` rows never conflict.
@@ -29,6 +30,10 @@ Define one consistent visibility model for project/worktree thread rows so empty
## Requirements
- Initial visible thread count per path is `DEFAULT_VISIBLE_THREAD_COUNT` (3).
- Visible thread count lookup order per normalized path is:
- in-memory runtime `visibleThreadCounts` entry,
- persisted tree UI state visible count,
- default value (`DEFAULT_VISIBLE_THREAD_COUNT`).
- For project rows, show `More` row only when `project.threads.size > visibleCount`.
- For worktree rows, show `More` row only when `worktree.threads.size > visibleCount`.
- If `hasUnknownThreadCount=true` for the corresponding node, `More` row must render without count (`toolwindow.action.more`).
@@ -41,15 +46,21 @@ Define one consistent visibility model for project/worktree thread rows so empty
- no provider warnings.
- Project/worktree error rows take precedence over provider warning and empty rows.
- Clicking a `More` row must increase visible count for that path by +3.
- Clicking a `More` row must persist that +3 increment to tree UI state for the same normalized path.
- `ensureThreadVisible(path, provider, threadId)` must raise visible count in +3 steps until the target thread is visible and persist the increment.
- Refresh bootstrap must restore persisted visible counts greater than default for known project/worktree paths.
- Persisted visibility keys must use normalized paths so `/path` and `/path/` resolve to one entry.
- Tree-side `More` click handling must not issue backend calls directly; it only updates visibility state.
[@test] ../sessions/testSrc/AgentSessionsToolWindowTest.kt
[@test] ../sessions/testSrc/AgentSessionsServiceRefreshIntegrationTest.kt
[@test] ../sessions/testSrc/AgentSessionsServiceOnDemandIntegrationTest.kt
## User Experience
- Exact count case: `More (N)`.
- Unknown count case: `More…`.
- Empty row is muted helper text and mutually exclusive with `More` for the same node.
- Non-default per-path thread visibility must persist across refresh/reopen for the same normalized path.
## Data & Backend
- Unknown-count state is produced by the service aggregation layer (`hasUnknownThreadCount`), not by tree rendering logic.
@@ -14,7 +14,7 @@ targets:
# Agent Threads Tool Window
Status: Draft
Date: 2026-02-15
Date: 2026-02-16
## Summary
Define the Agent Threads tool window as a provider-agnostic, project-scoped session browser. Threads from supported providers are aggregated per project/worktree, rendered in one tree, and opened through a shared chat routing flow.
@@ -39,6 +39,18 @@ Define the Agent Threads tool window as a provider-agnostic, project-scoped sess
- If at least one provider succeeds, successful threads must be shown and failed providers must surface provider-local warning rows.
- If all providers fail for a project/worktree load, show blocking project/worktree error state and suppress provider warning rows for that load.
- Unknown provider totals must propagate via `hasUnknownThreadCount` and drive unknown-count `More…` rendering.
- Sessions tree UI state must persist by normalized path:
- collapsed project/worktree state,
- per-path visible thread count,
- open-path thread preview cache.
- Refresh bootstrap must immediately seed open project/worktree nodes from cached previews when available and mark those paths loaded until live provider results arrive.
- Refresh bootstrap must restore persisted visible thread counts above default for known project/worktree paths.
- Refresh bootstrap must retain preview cache only for currently open project/worktree paths and prune stale closed-path entries.
- Final merged refresh results must update preview cache for a path only when that path does not end in blocking error.
- Auto-open default project expansion must skip paths persisted as collapsed.
- User collapse/expand interactions must update persisted collapsed state.
- Cached preview entries must preserve provider identity; missing legacy provider value must default to Codex for backward compatibility.
- `More` and programmatic visibility expansion (`ensureThreadVisible`) must persist visible-count increments in tree UI state.
- On-demand project/worktree loading must deduplicate concurrent requests for the same path.
- Concurrent refresh requests must be deduplicated while a refresh is already running.
- Project primary click must open/focus the project; closed projects must expose `Open` in context menu.
@@ -56,6 +68,7 @@ Define the Agent Threads tool window as a provider-agnostic, project-scoped sess
[@test] ../sessions/testSrc/AgentSessionsServiceOnDemandIntegrationTest.kt
[@test] ../sessions/testSrc/AgentSessionsServiceConcurrencyIntegrationTest.kt
[@test] ../sessions/testSrc/AgentSessionsToolWindowTest.kt
[@test] ../sessions/testSrc/AgentSessionsTreeUiStateServiceTest.kt
## User Experience
- Project rows are always expandable and may show worktree children.