IJPL-233558 IJ-MR-181153 initial version

GitOrigin-RevId: fec4a9abdb27dddc8feb1affbb74d6313d8369ac
This commit is contained in:
Vladimir Krivosheev
2026-02-03 11:56:23 +01:00
committed by intellij-monorepo-bot
parent 3386284b3c
commit c127ce6ec9
66 changed files with 6770 additions and 0 deletions

4
.idea/modules.xml generated
View File

@@ -47,6 +47,10 @@
<module fileurl="file://$PROJECT_DIR$/fleet/util/multiplatform/fleet.util.multiplatform.iml" filepath="$PROJECT_DIR$/fleet/util/multiplatform/fleet.util.multiplatform.iml" />
<module fileurl="file://$PROJECT_DIR$/fleet/util/network/fleet.util.network.iml" filepath="$PROJECT_DIR$/fleet/util/network/fleet.util.network.iml" />
<module fileurl="file://$PROJECT_DIR$/fleet/util/serialization/fleet.util.serialization.iml" filepath="$PROJECT_DIR$/fleet/util/serialization/fleet.util.serialization.iml" />
<module fileurl="file://$PROJECT_DIR$/plugins/agent-workbench/chat/intellij.agent.workbench.chat.iml" filepath="$PROJECT_DIR$/plugins/agent-workbench/chat/intellij.agent.workbench.chat.iml" />
<module fileurl="file://$PROJECT_DIR$/plugins/agent-workbench/codex/common/intellij.agent.workbench.codex.common.iml" filepath="$PROJECT_DIR$/plugins/agent-workbench/codex/common/intellij.agent.workbench.codex.common.iml" />
<module fileurl="file://$PROJECT_DIR$/plugins/agent-workbench/plugin/intellij.agent.workbench.plugin.iml" filepath="$PROJECT_DIR$/plugins/agent-workbench/plugin/intellij.agent.workbench.plugin.iml" />
<module fileurl="file://$PROJECT_DIR$/plugins/agent-workbench/sessions/intellij.agent.workbench.sessions.iml" filepath="$PROJECT_DIR$/plugins/agent-workbench/sessions/intellij.agent.workbench.sessions.iml" />
<module fileurl="file://$PROJECT_DIR$/android/android-adb/intellij.android.adb.iml" filepath="$PROJECT_DIR$/android/android-adb/intellij.android.adb.iml" />
<module fileurl="file://$PROJECT_DIR$/android/android-adb/intellij.android.adb.testUtil.iml" filepath="$PROJECT_DIR$/android/android-adb/intellij.android.adb.testUtil.iml" />
<module fileurl="file://$PROJECT_DIR$/android/android-adb/intellij.android.adb.tests.iml" filepath="$PROJECT_DIR$/android/android-adb/intellij.android.adb.tests.iml" />

View File

@@ -394,6 +394,11 @@ jvm_library(
"//plugins/git4idea/terminal",
"//plugins/git4idea/terminal:terminal_test_lib",
"//platform/instanceContainer:instanceContainer-tests_test_lib",
"//plugins/agent-workbench/chat",
"//plugins/agent-workbench/chat:chat_test_lib",
"//plugins/agent-workbench/plugin:plugin_test_lib",
"//plugins/agent-workbench/sessions",
"//plugins/agent-workbench/sessions:sessions_test_lib",
"//platform/polySymbols:polySymbols-tests_test_lib",
]
)

View File

@@ -867,6 +867,10 @@ plugins/ByteCodeViewer
plugins/IntelliLang
plugins/IntelliLang/backend
plugins/IntelliLang/tests
plugins/agent-workbench/chat
plugins/agent-workbench/codex/common
plugins/agent-workbench/plugin
plugins/agent-workbench/sessions
plugins/ant
plugins/ant/jps-plugin
plugins/built-in-help

View File

@@ -266,6 +266,9 @@
<orderEntry type="module" module-name="intellij.vcs.git.terminal" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.plugin" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.platform.instanceContainer.tests" scope="TEST" />
<orderEntry type="module" module-name="intellij.agent.workbench.chat" scope="TEST" />
<orderEntry type="module" module-name="intellij.agent.workbench.plugin" scope="TEST" />
<orderEntry type="module" module-name="intellij.agent.workbench.sessions" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.polySymbols.tests" scope="TEST" />
</component>
</module>

View File

@@ -0,0 +1,11 @@
# Agent Workbench Plugin Local Notes
This file captures development references only. Product/design decisions live in the spec under `spec/`.
- `pluginId`: `com.intellij.agent.workbench`
- Tool window ID: `agent.workbench.sessions`
- Tool window title: "Agent Threads"
- Main module: `intellij.agent.workbench.plugin` (plugin.xml)
- Content module: `intellij.agent.workbench.sessions`
- Spec format (single source): `spec-format/SPEC_GUIDE.md` (specs live under `spec/`).
- Issue tracker: https://github.com/JetBrains/agent-workbench

View File

@@ -0,0 +1,72 @@
# Agent Workbench Plugin
## Vision: AI-First Workflow UX
The Agent Workbench plugin reimagines the IDE experience around AI-assisted development. Rather than treating AI as an add-on feature, the plugin creates a seamless workflow where developers can:
- **Start conversations naturally** - Begin coding discussions from any context in the IDE
- **Maintain persistent threads** - Keep conversation history organized and accessible across sessions
- **Navigate AI interactions** - Browse, search, and resume previous conversations efficiently
- **Integrate with development flow** - Connect AI assistance directly to code navigation, editing, and debugging
The goal is to make AI assistance feel like a native part of the development environment, reducing context switching and keeping developers in flow.
## Architecture
The plugin provides two complementary views for working with AI-assisted development:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ IntelliJ IDEA │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ PROJECT FRAME │ │ AI-CHAT DEDICATED VIEW │ │
│ │ (traditional development) │ │ (task orchestration) │ │
│ │ │ │ │ │
│ │ ┌────────┐ ┌────────────────┐ │ │ ┌────────┐ ┌────────────────┐ │ │
│ │ │ Agent │ │ │ │ │ │ Agent │ │ │ │ │
│ │ │Sessions│ │ Editor │ │◄─►│ │Sessions│ │ Chat Panel │ │ │
│ │ │ Tool │ │ │ │ │ │ Tool │ │ │ │ │
│ │ │ Window │ │ │ │ │ │ Window │ │ • Status │ │ │
│ │ │ │ │ • Navigate │ │ │ │ │ │ • Input │ │ │
│ │ │Projects│ │ • Edit │ │ │ │Projects│ │ • History │ │ │
│ │ │ └─Th. │ │ • Debug │ │ │ │ └─Th. │ │ │ │ │
│ │ │ └─… │ │ • Review VCS │ │ │ │ └─… │ │ │ │ │
│ │ └────────┘ └────────────────┘ │ │ └────────┘ └────────────────┘ │ │
│ └─────────────────────────────────┘ └─────────────────────────────────┘ │
│ │
│ Why dual views? │
│ • Work on multiple tasks in parallel — see status of each │
│ • AI isn't "there" yet — you still review, read, understand code │
│ • Not vibe-coding — we use AI in production, need to know how/why │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
The Agent Threads Tool Window organizes conversations by project:
```
Projects
├── project-a
│ ├── Thread: "Add caching layer" [done]
│ └── Thread: "Fix auth bug" [needs input]
│ └── sub-agent: "research"
└── project-b (not open) [Connect]
└── Thread: "Refactor API" [inactive]
```
## Specifications
Detailed requirements and testing contracts are documented in the spec files:
- [Agent Threads Tool Window](spec/codex-sessions.spec.md) - Requirements for the Sessions tool window UI, session management, and user interactions
- [Testing Contract](spec/codex-sessions-testing.spec.md) - Testing strategy, UI coverage requirements, and verification criteria
## Test All
Run all Agent Workbench tests with:
```bash
./tests.cmd '-Dintellij.build.test.patterns=com.intellij.agent.workbench.*'
```

View File

@@ -0,0 +1,67 @@
### auto-generated section `build intellij.agent.workbench.chat` start
load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
resourcegroup(
name = "chat_resources",
srcs = glob(["resources/**/*"]),
strip_prefix = "resources"
)
jvm_library(
name = "chat",
module_name = "intellij.agent.workbench.chat",
visibility = ["//visibility:public"],
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
resources = [":chat_resources"],
deps = [
"@lib//:kotlin-stdlib",
"//platform/analysis-api:analysis",
"//platform/core-api:core",
"//platform/core-ui",
"//platform/platform-api:ide",
"//platform/platform-impl:ide-impl",
"//platform/ide-core-impl",
"//platform/projectModel-api:projectModel",
"//platform/util",
"//platform/platform-util-io:ide-util-io",
"//platform/execution-impl",
"//plugins/agent-workbench/codex/common",
"//plugins/terminal/frontend",
]
)
jvm_library(
name = "chat_test_lib",
visibility = ["//visibility:public"],
srcs = glob(["testSrc/**/*.kt", "testSrc/**/*.java", "testSrc/**/*.form"], allow_empty = True),
associates = [":chat"],
deps = [
"@lib//:kotlin-stdlib",
"//platform/analysis-api:analysis",
"//platform/core-api:core",
"//platform/core-ui",
"//platform/platform-api:ide",
"//platform/platform-impl:ide-impl",
"//platform/ide-core-impl",
"//platform/projectModel-api:projectModel",
"//platform/util",
"//platform/platform-util-io:ide-util-io",
"//platform/execution-impl",
"//plugins/agent-workbench/codex/common",
"//plugins/terminal/frontend",
"//platform/testFramework",
"//platform/testFramework:testFramework_test_lib",
"//libraries/junit4",
"//libraries/assertj-core",
]
)
### auto-generated section `build intellij.agent.workbench.chat` end
### auto-generated section `test intellij.agent.workbench.chat` start
load("@community//build:tests-options.bzl", "jps_test")
jps_test(
name = "chat_test",
runtime_deps = [":chat_test_lib"]
)
### auto-generated section `test intellij.agent.workbench.chat` end

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="5" platform="JVM 21" allPlatforms="JVM [21]" useProjectSettings="false">
<compilerSettings>
<option name="additionalArguments" value="-Xjvm-default=all -XXLanguage:+AllowEagerSupertypeAccessibilityChecks -progressive" />
</compilerSettings>
<compilerArguments>
<stringArguments>
<stringArg name="jvmTarget" arg="21" />
<stringArg name="apiVersion" arg="2.3" />
<stringArg name="languageVersion" arg="2.3" />
</stringArguments>
<arrayArguments>
<arrayArg name="pluginClasspaths">
<args>$KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar</args>
</arrayArg>
</arrayArguments>
</compilerArguments>
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="com.intellij.agent.workbench.chat" />
<sourceFolder url="file://$MODULE_DIR$/testSrc" isTestSource="true" packagePrefix="com.intellij.agent.workbench.chat" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />
<orderEntry type="module" module-name="intellij.platform.analysis" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.platform.core.ui" />
<orderEntry type="module" module-name="intellij.platform.ide" />
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
<orderEntry type="module" module-name="intellij.platform.ide.core.impl" />
<orderEntry type="module" module-name="intellij.platform.projectModel" />
<orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="intellij.platform.ide.util.io" />
<orderEntry type="module" module-name="intellij.platform.execution.impl" />
<orderEntry type="module" module-name="intellij.agent.workbench.codex.common" />
<orderEntry type="module" module-name="intellij.terminal.frontend" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.junit4" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.assertj.core" scope="TEST" />
</component>
</module>

View File

@@ -0,0 +1,13 @@
<idea-plugin>
<!-- region Generated dependencies - run `Generate Product Layouts` to regenerate -->
<dependencies>
<module name="intellij.agent.workbench.codex.common"/>
<module name="intellij.terminal.frontend"/>
</dependencies>
<!-- endregion -->
<resource-bundle>messages.CodexChatBundle</resource-bundle>
<extensions defaultExtensionNs="com.intellij">
<fileEditorProvider id="agent.workbench-chat-editor" implementation="com.intellij.agent.workbench.chat.CodexChatFileEditorProvider"/>
</extensions>
</idea-plugin>

View File

@@ -0,0 +1,2 @@
chat.filetype.name=Agent Chat
chat.filetype.description=Agent chat session

View File

@@ -0,0 +1,17 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.chat
import com.intellij.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.PropertyKey
const val CODEX_CHAT_BUNDLE: @NonNls String = "messages.CodexChatBundle"
internal object CodexChatBundle {
private val BUNDLE = DynamicBundle(CodexChatBundle::class.java, CODEX_CHAT_BUNDLE)
fun message(key: @PropertyKey(resourceBundle = CODEX_CHAT_BUNDLE) String, vararg params: Any): @Nls String {
return BUNDLE.getMessage(key, *params)
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.chat
import com.intellij.openapi.components.Service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
@Service(Service.Level.PROJECT)
@Suppress("unused")
class CodexChatEditorService(private val project: Project) {
fun openChat(
projectPath: String,
threadId: String,
threadTitle: String,
subAgentId: String?,
) {
val manager = FileEditorManager.getInstance(project)
val existing = findExistingChat(manager.openFiles, threadId, subAgentId)
val file = existing ?: CodexChatVirtualFile(
projectPath = projectPath,
threadId = threadId,
threadTitle = threadTitle,
subAgentId = subAgentId,
)
manager.openFile(file, true)
}
private fun findExistingChat(openFiles: Array<VirtualFile>, threadId: String, subAgentId: String?): CodexChatVirtualFile? {
return openFiles
.filterIsInstance<CodexChatVirtualFile>()
.firstOrNull { it.matches(threadId, subAgentId) }
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.chat
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorState
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.UserDataHolderBase
import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTab
import java.beans.PropertyChangeListener
import javax.swing.JComponent
internal class CodexChatFileEditor(
private val file: CodexChatVirtualFile,
private val tab: TerminalToolWindowTab,
) : UserDataHolderBase(), FileEditor {
private val component = tab.content.component
override fun getComponent(): JComponent = component
override fun getPreferredFocusedComponent(): JComponent = tab.view.preferredFocusableComponent
override fun getName(): String = file.name
override fun setState(state: FileEditorState) = Unit
override fun isModified(): Boolean = false
override fun isValid(): Boolean = true
override fun addPropertyChangeListener(listener: PropertyChangeListener) = Unit
override fun removePropertyChangeListener(listener: PropertyChangeListener) = Unit
override fun getFile(): CodexChatVirtualFile = file
override fun dispose() {
Disposer.dispose(tab.content)
}
}

View File

@@ -0,0 +1,58 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.chat
import com.intellij.openapi.application.EDT
import com.intellij.openapi.editor.Document
import com.intellij.openapi.fileEditor.AsyncFileEditorProvider
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorPolicy
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class CodexChatFileEditorProvider : AsyncFileEditorProvider {
override fun accept(project: Project, file: VirtualFile): Boolean = file is CodexChatVirtualFile
override fun acceptRequiresReadAction(): Boolean = false
override suspend fun createFileEditor(
project: Project,
file: VirtualFile,
document: Document?,
editorCoroutineScope: CoroutineScope,
): FileEditor {
return withContext(Dispatchers.EDT) {
createChatEditor(project, file as CodexChatVirtualFile)
}
}
override fun createEditor(project: Project, file: VirtualFile): FileEditor {
return createChatEditor(project, file as CodexChatVirtualFile)
}
override fun getEditorTypeId(): String = "agent.workbench-chat-editor"
override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR
}
private fun createChatEditor(project: Project, file: CodexChatVirtualFile): FileEditor {
val terminalManager = TerminalToolWindowTabsManager.getInstance(project)
val tab = terminalManager.createTabBuilder()
.shouldAddToToolWindow(false)
.workingDirectory(file.projectPath)
.tabName(file.name)
.shellCommand(buildShellCommand(file))
.createTab()
return CodexChatFileEditor(file, tab)
}
private fun buildShellCommand(file: CodexChatVirtualFile): List<String> {
return buildCodexResumeShellCommand(file.threadId)
}
internal fun buildCodexResumeShellCommand(threadId: String): List<String> {
return listOf("codex", "-c", "check_for_update_on_startup=false", "resume", threadId)
}

View File

@@ -0,0 +1,17 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.chat
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileTypes.ex.FakeFileType
import com.intellij.openapi.vfs.VirtualFile
import javax.swing.Icon
internal object CodexChatFileType : FakeFileType() {
override fun getName(): String = CodexChatBundle.message("chat.filetype.name")
override fun getDescription(): String = CodexChatBundle.message("chat.filetype.description")
override fun getIcon(): Icon = AllIcons.Toolwindows.ToolWindowMessages
override fun isMyFileType(file: VirtualFile): Boolean = file is CodexChatVirtualFile
}

View File

@@ -0,0 +1,24 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.chat
import com.intellij.testFramework.LightVirtualFile
internal class CodexChatVirtualFile(
val projectPath: String,
val threadId: String,
val threadTitle: String,
val subAgentId: String?,
) : LightVirtualFile(resolveFileName(threadTitle)) {
init {
fileType = CodexChatFileType
isWritable = false
}
fun matches(threadId: String, subAgentId: String?): Boolean {
return this.threadId == threadId && this.subAgentId == subAgentId
}
}
private fun resolveFileName(threadTitle: String): String {
return threadTitle.takeIf { it.isNotBlank() } ?: CodexChatBundle.message("chat.filetype.name")
}

View File

@@ -0,0 +1,115 @@
package com.intellij.agent.workbench.chat
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorPolicy
import com.intellij.openapi.fileEditor.FileEditorProvider
import com.intellij.openapi.fileEditor.FileEditorState
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.UserDataHolderBase
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.testFramework.FileEditorManagerTestCase
import com.intellij.testFramework.runInEdtAndWait
import org.assertj.core.api.Assertions.assertThat
import java.beans.PropertyChangeListener
import javax.swing.JComponent
import javax.swing.JPanel
class CodexChatEditorServiceTest : FileEditorManagerTestCase() {
override fun setUp() {
super.setUp()
FileEditorProvider.EP_FILE_EDITOR_PROVIDER.point.registerExtension(TestChatFileEditorProvider(), testRootDisposable)
}
fun testReuseEditorForThread() {
val service = project.service<CodexChatEditorService>()
runInEdtAndWait {
service.openChat("/work/project-a", "thread-1", "Fix auth bug", null)
service.openChat("/work/project-a", "thread-1", "Fix auth bug", null)
}
val files = openedChatFiles()
assertThat(files).hasSize(1)
}
fun testSeparateTabsForSubAgents() {
val service = project.service<CodexChatEditorService>()
runInEdtAndWait {
service.openChat("/work/project-a", "thread-1", "Fix auth bug", "alpha")
service.openChat("/work/project-a", "thread-1", "Fix auth bug", "beta")
}
val files = openedChatFiles()
assertThat(files).hasSize(2)
}
fun testTabTitleUsesThreadTitle() {
val service = project.service<CodexChatEditorService>()
val title = "Investigate crash"
runInEdtAndWait {
service.openChat("/work/project-a", "thread-2", title, null)
}
val file = openedChatFiles().single()
assertThat(file.name).isEqualTo(title)
}
fun testResumeCommandDisablesUpdateCheck() {
assertThat(buildCodexResumeShellCommand("thread-1")).containsExactly(
"codex",
"-c",
"check_for_update_on_startup=false",
"resume",
"thread-1",
)
}
private fun openedChatFiles(): List<CodexChatVirtualFile> {
return FileEditorManager.getInstance(project).openFiles.filterIsInstance<CodexChatVirtualFile>()
}
}
private class TestChatFileEditorProvider : FileEditorProvider, DumbAware {
override fun accept(project: Project, file: VirtualFile): Boolean {
return file is CodexChatVirtualFile
}
override fun acceptRequiresReadAction(): Boolean = false
override fun createEditor(project: Project, file: VirtualFile): FileEditor {
return TestChatFileEditor(file)
}
override fun getEditorTypeId(): String = "agent.workbench-chat-editor-test"
override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_OTHER_EDITORS
}
private class TestChatFileEditor(private val file: VirtualFile) : UserDataHolderBase(), FileEditor {
private val component = JPanel()
override fun getComponent(): JComponent = component
override fun getPreferredFocusedComponent(): JComponent = component
override fun getName(): String = "CodexChatTestEditor"
override fun setState(state: FileEditorState) = Unit
override fun isModified(): Boolean = false
override fun isValid(): Boolean = true
override fun addPropertyChangeListener(listener: PropertyChangeListener) = Unit
override fun removePropertyChangeListener(listener: PropertyChangeListener) = Unit
override fun getFile(): VirtualFile = file
override fun dispose() = Unit
}

View File

@@ -0,0 +1,26 @@
### auto-generated section `build intellij.agent.workbench.codex.common` start
load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
resourcegroup(
name = "common_resources",
srcs = glob(["resources/**/*"]),
strip_prefix = "resources"
)
jvm_library(
name = "common",
module_name = "intellij.agent.workbench.codex.common",
visibility = ["//visibility:public"],
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
resources = [":common_resources"],
deps = [
"@lib//:kotlin-stdlib",
"//platform/compose",
"//platform/core-api:core",
"//platform/util",
"//platform/execution-impl",
"//libraries/jackson/jackson",
"//platform/platform-util-io:ide-util-io",
]
)
### auto-generated section `build intellij.agent.workbench.codex.common` end

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="com.intellij.agent.workbench.codex.common" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />
<orderEntry type="module" module-name="intellij.platform.compose" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="intellij.platform.execution.impl" />
<orderEntry type="module" module-name="intellij.libraries.jackson" />
<orderEntry type="module" module-name="intellij.platform.ide.util.io" />
</component>
</module>

View File

@@ -0,0 +1,7 @@
<idea-plugin>
<!-- region Generated dependencies - run `Generate Product Layouts` to regenerate -->
<dependencies>
<module name="intellij.platform.compose"/>
</dependencies>
<!-- endregion -->
</idea-plugin>

View File

@@ -0,0 +1,409 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.codex.common
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
import com.intellij.openapi.diagnostic.debug
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.io.awaitExit
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
private const val CODEX_COMMAND = "codex"
private const val REQUEST_TIMEOUT_MS = 30_000L
private const val MAX_PAGES = 10
private const val PAGE_LIMIT = 50
private val LOG = logger<CodexAppServerClient>()
class CodexAppServerClient(
private val coroutineScope: CoroutineScope,
private val executablePathProvider: () -> String? = {
PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS(CODEX_COMMAND)?.absolutePath
},
private val environmentOverrides: Map<String, String> = emptyMap(),
private val workingDirectory: Path? = null,
) {
private val pending = ConcurrentHashMap<String, CompletableDeferred<String>>()
private val requestCounter = AtomicLong(0)
private val writeMutex = Mutex()
private val startMutex = Mutex()
private val initMutex = Mutex()
private val protocol = CodexAppServerProtocol(workingDirectory)
@Volatile
private var process: Process? = null
@Volatile
private var initialized = false
private var writer: BufferedWriter? = null
private var readerJob: Job? = null
private var stderrJob: Job? = null
private var waitJob: Job? = null
suspend fun listThreadsPage(
archived: Boolean,
cursor: String? = null,
limit: Int = PAGE_LIMIT,
): CodexThreadPage {
val resolvedLimit = limit.coerceAtLeast(1)
val response = request(
method = "thread/list",
paramsWriter = { generator ->
generator.writeStartObject()
generator.writeNumberField("limit", resolvedLimit)
generator.writeStringField("order", "desc")
generator.writeStringField("sortKey", "updated_at")
generator.writeBooleanField("archived", archived)
cursor?.let { generator.writeStringField("cursor", it) }
generator.writeEndObject()
},
resultParser = { parser -> protocol.parseThreadListResult(parser, archived) },
defaultResult = ThreadListResult(emptyList(), null),
)
return CodexThreadPage(
threads = response.threads.sortedByDescending { it.updatedAt },
nextCursor = response.nextCursor,
)
}
suspend fun listThreads(archived: Boolean): List<CodexThread> {
val threads = mutableListOf<CodexThread>()
var cursor: String? = null
var pages = 0
do {
val response = listThreadsPage(
archived = archived,
cursor = cursor,
limit = PAGE_LIMIT,
)
threads.addAll(response.threads)
cursor = response.nextCursor
pages++
} while (!cursor.isNullOrBlank() && pages < MAX_PAGES)
return threads.sortedByDescending { it.updatedAt }
}
suspend fun createThread(): CodexThread {
val thread = request(
method = "thread/start",
paramsWriter = { generator ->
generator.writeStartObject()
generator.writeEndObject()
},
resultParser = { parser -> protocol.parseThreadStartResult(parser) },
defaultResult = null,
)
return thread ?: throw CodexAppServerException("Codex app-server returned empty thread/start result")
}
@Suppress("unused")
suspend fun archiveThread(id: String) {
requestUnit(method = "thread/archive", paramsWriter = { generator ->
generator.writeStartObject()
generator.writeStringField("id", id)
generator.writeEndObject()
})
}
@Suppress("unused")
suspend fun unarchiveThread(id: String) {
requestUnit(method = "thread/unarchive", paramsWriter = { generator ->
generator.writeStartObject()
generator.writeStringField("id", id)
generator.writeEndObject()
})
}
fun shutdown() {
stopProcess()
}
private suspend fun <T> request(
method: String,
paramsWriter: ((JsonGenerator) -> Unit)? = null,
ensureInitialized: Boolean = true,
resultParser: (JsonParser) -> T,
defaultResult: T,
): T {
if (ensureInitialized) ensureInitialized() else ensureProcess()
val id = requestCounter.incrementAndGet().toString()
val deferred = CompletableDeferred<String>()
pending[id] = deferred
try {
sendRequest(id, method, paramsWriter)
val response = withTimeout(REQUEST_TIMEOUT_MS) { deferred.await() }
return protocol.parseResponse(response, resultParser, defaultResult)
}
catch (t: TimeoutCancellationException) {
throw CodexAppServerException("Codex request timed out", t)
}
finally {
pending.remove(id)
}
}
private suspend fun requestUnit(
method: String,
paramsWriter: ((JsonGenerator) -> Unit)? = null,
ensureInitialized: Boolean = true,
) {
request(
method = method,
paramsWriter = paramsWriter,
ensureInitialized = ensureInitialized,
resultParser = { parser ->
parser.skipChildren()
Unit
},
defaultResult = Unit,
)
}
private suspend fun sendRequest(id: String, method: String, paramsWriter: ((JsonGenerator) -> Unit)?) {
send { generator ->
generator.writeStartObject()
generator.writeStringField("id", id)
generator.writeStringField("method", method)
if (paramsWriter != null) {
generator.writeFieldName("params")
paramsWriter(generator)
}
generator.writeEndObject()
}
}
private suspend fun sendNotification(method: String, paramsWriter: ((JsonGenerator) -> Unit)? = null) {
send { generator ->
generator.writeStartObject()
generator.writeStringField("method", method)
if (paramsWriter != null) {
generator.writeFieldName("params")
paramsWriter(generator)
}
generator.writeEndObject()
}
}
private suspend fun send(payloadWriter: (JsonGenerator) -> Unit) {
val activeProcess = ensureProcess()
writeMutex.withLock {
val out = writer ?: throw CodexAppServerException("Codex app-server output is not available")
if (!activeProcess.isAlive) throw CodexAppServerException("Codex app-server is not running")
protocol.writePayload(out, payloadWriter)
out.newLine()
out.flush()
}
}
private suspend fun ensureInitialized() {
ensureProcess()
if (initialized) return
initMutex.withLock {
if (initialized) return
requestUnit(
method = "initialize",
paramsWriter = { generator ->
generator.writeStartObject()
generator.writeFieldName("clientInfo")
generator.writeStartObject()
generator.writeStringField("name", "IntelliJ Agent Workbench")
generator.writeStringField("version", "1.0")
generator.writeEndObject()
generator.writeEndObject()
},
ensureInitialized = false,
)
sendNotification("initialized")
initialized = true
}
}
private suspend fun ensureProcess(): Process {
val current = process
if (current != null && current.isAlive) return current
return startMutex.withLock {
val existing = process
if (existing != null && existing.isAlive) return existing
startProcess()
}
}
private fun startProcess(): Process {
val executable = executablePathProvider() ?: throw CodexCliNotFoundException()
val process = try {
ProcessBuilder(executable, "app-server").apply {
if (environmentOverrides.isNotEmpty()) {
val env = environment()
for ((key, value) in environmentOverrides) {
env[key] = value
}
}
val directory = workingDirectory
if (directory != null && Files.isDirectory(directory)) {
@Suppress("IO_FILE_USAGE")
directory(directory.toFile())
}
}
.redirectErrorStream(false)
.start()
}
catch (t: Throwable) {
throw CodexAppServerException("Failed to start Codex app-server", t)
}
this.process = process
this.writer = BufferedWriter(OutputStreamWriter(process.outputStream, StandardCharsets.UTF_8))
startReader(process)
startStderrReader(process)
startWaiter(process)
initialized = false
return process
}
private fun startReader(process: Process) {
readerJob?.cancel()
readerJob = coroutineScope.launch(Dispatchers.IO) {
val reader = BufferedReader(InputStreamReader(process.inputStream, StandardCharsets.UTF_8))
try {
while (isActive) {
val line = runInterruptible { reader.readLine() } ?: break
val payload = line.trim()
if (payload.isEmpty()) continue
handleMessage(payload)
}
}
catch (e: Throwable) {
if (!isActive || !process.isAlive) {
return@launch
}
LOG.warn("Codex app-server stdout reader failed", e)
}
finally {
try {
reader.close()
}
catch (_: Throwable) {
}
}
}
}
private fun startStderrReader(process: Process) {
stderrJob?.cancel()
stderrJob = coroutineScope.launch(Dispatchers.IO) {
val reader = BufferedReader(InputStreamReader(process.errorStream, StandardCharsets.UTF_8))
try {
while (isActive) {
val line = runInterruptible { reader.readLine() } ?: break
if (line.isNotBlank()) {
LOG.debug { "Codex app-server stderr: $line" }
}
}
}
catch (e: Throwable) {
if (!isActive || !process.isAlive) return@launch
LOG.error("Codex app-server stderr reader failed", e)
}
finally {
try {
reader.close()
}
catch (_: Throwable) {
}
}
}
}
private fun startWaiter(process: Process) {
waitJob?.cancel()
waitJob = coroutineScope.launch(Dispatchers.IO) {
try {
process.awaitExit()
}
catch (_: Throwable) {
return@launch
}
handleProcessExit()
}
}
private fun handleMessage(payload: String) {
val id = try {
protocol.parseMessageId(payload)
}
catch (e: Throwable) {
LOG.error("Failed to parse Codex app-server payload: $payload", e)
return
}
if (id == null) {
return
}
pending.remove(id)?.complete(payload)
}
private fun handleProcessExit() {
val error = CodexAppServerException("Codex app-server terminated")
pending.values.forEach { it.completeExceptionally(error) }
pending.clear()
process = null
writer = null
initialized = false
}
private fun stopProcess() {
val current = process ?: return
process = null
initialized = false
try {
writer?.close()
}
catch (_: Throwable) {
}
writer = null
try {
current.outputStream.close()
}
catch (_: Throwable) {
}
try {
current.inputStream.close()
}
catch (_: Throwable) {
}
try {
current.errorStream.close()
}
catch (_: Throwable) {
}
readerJob?.cancel()
stderrJob?.cancel()
waitJob?.cancel()
current.destroy()
}
}
open class CodexAppServerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
class CodexCliNotFoundException : CodexAppServerException("Codex CLI not found")

View File

@@ -0,0 +1,263 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.codex.common
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import java.io.Writer
import java.nio.file.Path
import kotlin.io.path.invariantSeparatorsPathString
private const val MAX_TITLE_LENGTH = 120
internal data class ThreadListResult(
val threads: List<CodexThread>,
val nextCursor: String?,
)
internal class CodexAppServerProtocol(workingDirectory: Path?) {
private val jsonFactory = JsonFactory()
// Normalized path string (invariant separators, no trailing slash) used to filter threads by cwd.
private val cwdFilter = workingDirectory?.let { normalizeRootPath(it.invariantSeparatorsPathString) }
fun writePayload(out: Writer, payloadWriter: (JsonGenerator) -> Unit) {
val generator = jsonFactory.createGenerator(out)
generator.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET)
payloadWriter(generator)
generator.close()
}
fun <T> parseResponse(payload: String, resultParser: (JsonParser) -> T, defaultResult: T): T {
jsonFactory.createParser(payload).use { parser ->
if (parser.nextToken() != JsonToken.START_OBJECT) return defaultResult
var result: T = defaultResult
var hasResult = false
var errorSeen = false
var errorMessage: String? = null
forEachObjectField(parser) { fieldName ->
when (fieldName) {
"result" -> {
result = resultParser(parser)
hasResult = true
}
"error" -> {
errorSeen = true
errorMessage = readErrorMessage(parser)
}
else -> parser.skipChildren()
}
true
}
if (errorSeen) {
throw CodexAppServerException(errorMessage ?: "Codex app-server error")
}
return if (hasResult) result else defaultResult
}
}
fun parseMessageId(payload: String): String? {
jsonFactory.createParser(payload).use { parser ->
if (parser.nextToken() != JsonToken.START_OBJECT) return null
var id: String? = null
forEachObjectField(parser) { fieldName ->
if (fieldName == "id") {
id = readStringOrNull(parser)
return@forEachObjectField false
}
parser.skipChildren()
true
}
return id
}
}
fun parseThreadListResult(parser: JsonParser, archived: Boolean): ThreadListResult {
if (parser.currentToken != JsonToken.START_OBJECT) {
parser.skipChildren()
return ThreadListResult(threads = emptyList(), nextCursor = null)
}
val threads = mutableListOf<CodexThread>()
var nextCursor: String? = null
forEachObjectField(parser) { fieldName ->
when (fieldName) {
"data" -> if (parser.currentToken == JsonToken.START_ARRAY) parseThreadArray(parser, archived, threads, cwdFilter) else parser.skipChildren()
"nextCursor", "next_cursor" -> nextCursor = readStringOrNull(parser)
else -> parser.skipChildren()
}
true
}
return ThreadListResult(threads, nextCursor)
}
fun parseThreadStartResult(parser: JsonParser): CodexThread {
if (parser.currentToken != JsonToken.START_OBJECT) {
parser.skipChildren()
throw CodexAppServerException("Codex app-server returned invalid thread/start result")
}
return parseThreadFromResultObject(parser)
?: throw CodexAppServerException("Codex app-server returned thread/start result without thread data")
}
}
private fun parseThreadArray(parser: JsonParser, archived: Boolean, threads: MutableList<CodexThread>, cwdFilter: String?) {
while (true) {
val token = parser.nextToken() ?: return
if (token == JsonToken.END_ARRAY) return
if (token == JsonToken.START_OBJECT) {
parseThreadObject(parser, archived, cwdFilter)?.let(threads::add)
}
else {
parser.skipChildren()
}
}
}
private fun parseThreadObject(parser: JsonParser, archived: Boolean, cwdFilter: String?): CodexThread? {
val payload = parseThreadPayload(parser, allowNestedThread = false)
val threadId = payload.id ?: return null
if (!cwdFilter.isNullOrBlank()) {
val normalizedCwd = payload.cwd?.let(::normalizeRootPath)
if (normalizedCwd.isNullOrBlank() || normalizedCwd != cwdFilter) {
return null
}
}
val updatedAtValue = normalizeTimestamp(
payload.updatedAt
?: payload.updatedAtAlt
?: payload.createdAt
?: payload.createdAtAlt
?: 0L
)
val previewValue = payload.preview ?: payload.title ?: payload.name ?: payload.summary
val threadTitle = previewValue?.let { trimTitle(it) }?.takeIf { it.isNotBlank() } ?: "Thread ${threadId.take(8)}"
return CodexThread(id = threadId, title = threadTitle, updatedAt = updatedAtValue, archived = archived)
}
private fun parseThreadFromResultObject(parser: JsonParser): CodexThread? {
val payload = parseThreadPayload(parser, allowNestedThread = true)
if (payload.nestedThread != null) return payload.nestedThread
val threadId = payload.id ?: return null
val updatedAtValue = normalizeTimestamp(
payload.updatedAt
?: payload.updatedAtAlt
?: payload.createdAt
?: payload.createdAtAlt
?: 0L
)
val previewValue = payload.preview ?: payload.title ?: payload.name ?: payload.summary
val threadTitle = previewValue?.let(::trimTitle)?.takeIf { it.isNotBlank() } ?: "Thread ${threadId.take(8)}"
return CodexThread(id = threadId, title = threadTitle, updatedAt = updatedAtValue, archived = false)
}
private data class ThreadPayload(
val id: String?,
val updatedAt: Long?,
val updatedAtAlt: Long?,
val createdAt: Long?,
val createdAtAlt: Long?,
val preview: String?,
val title: String?,
val name: String?,
val summary: String?,
val cwd: String?,
val nestedThread: CodexThread?,
)
private fun parseThreadPayload(parser: JsonParser, allowNestedThread: Boolean): ThreadPayload {
var id: String? = null
var updatedAt: Long? = null
var updatedAtAlt: Long? = null
var createdAt: Long? = null
var createdAtAlt: Long? = null
var preview: String? = null
var title: String? = null
var name: String? = null
var summary: String? = null
var cwd: String? = null
var nestedThread: CodexThread? = null
forEachObjectField(parser) { fieldName ->
when (fieldName) {
"thread", "data" -> {
if (allowNestedThread && parser.currentToken == JsonToken.START_OBJECT) {
nestedThread = parseThreadObject(parser, archived = false, cwdFilter = null)
}
else {
parser.skipChildren()
}
}
"id" -> id = readStringOrNull(parser)
"updatedAt" -> updatedAt = readLongOrNull(parser)
"updated_at" -> updatedAtAlt = readLongOrNull(parser)
"createdAt" -> createdAt = readLongOrNull(parser)
"created_at" -> createdAtAlt = readLongOrNull(parser)
"preview" -> preview = readStringOrNull(parser)
"title" -> title = readStringOrNull(parser)
"name" -> name = readStringOrNull(parser)
"summary" -> summary = readStringOrNull(parser)
"cwd" -> cwd = readStringOrNull(parser)
else -> parser.skipChildren()
}
true
}
return ThreadPayload(
id = id,
updatedAt = updatedAt,
updatedAtAlt = updatedAtAlt,
createdAt = createdAt,
createdAtAlt = createdAtAlt,
preview = preview,
title = title,
name = name,
summary = summary,
cwd = cwd,
nestedThread = nestedThread,
)
}
private fun normalizeTimestamp(value: Long): Long {
if (value <= 0L) {
return 0L
}
return if (value < 100_000_000_000L) value * 1000L else value
}
private fun trimTitle(value: String): String {
val trimmed = value.trim()
if (trimmed.length <= MAX_TITLE_LENGTH) {
return trimmed
}
return trimmed.take(MAX_TITLE_LENGTH - 3).trimEnd() + "..."
}
private fun normalizeRootPath(value: String): String {
return value.replace('\\', '/').trimEnd('/')
}
private fun readErrorMessage(parser: JsonParser): String? {
return when (parser.currentToken) {
JsonToken.VALUE_STRING -> parser.text
JsonToken.START_OBJECT -> {
var message: String? = null
forEachObjectField(parser) { fieldName ->
if (fieldName == "message") {
message = readStringOrNull(parser)
}
else {
parser.skipChildren()
}
true
}
message
}
else -> {
parser.skipChildren()
null
}
}
}

View File

@@ -0,0 +1,45 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.codex.common
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
inline fun forEachObjectField(parser: JsonParser, onField: (String) -> Boolean) {
while (true) {
val token = parser.nextToken() ?: return
if (token == JsonToken.END_OBJECT) return
if (token != JsonToken.FIELD_NAME) {
parser.skipChildren()
continue
}
val fieldName = parser.currentName()
if (parser.nextToken() == null) return
if (!onField(fieldName)) return
}
}
fun readStringOrNull(parser: JsonParser): String? {
return when (parser.currentToken) {
JsonToken.VALUE_STRING -> parser.text
JsonToken.VALUE_NUMBER_INT, JsonToken.VALUE_NUMBER_FLOAT -> parser.numberValue.toString()
JsonToken.VALUE_TRUE, JsonToken.VALUE_FALSE -> parser.booleanValue.toString()
JsonToken.VALUE_NULL -> null
else -> {
parser.skipChildren()
null
}
}
}
fun readLongOrNull(parser: JsonParser): Long? {
return when (parser.currentToken) {
JsonToken.VALUE_NUMBER_INT -> parser.longValue
JsonToken.VALUE_NUMBER_FLOAT -> parser.doubleValue.toLong()
JsonToken.VALUE_STRING -> parser.text.toLongOrNull() ?: parser.text.toDoubleOrNull()?.toLong()
JsonToken.VALUE_NULL -> null
else -> {
parser.skipChildren()
null
}
}
}

View File

@@ -0,0 +1,45 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.codex.common
import androidx.compose.runtime.Immutable
@Immutable
data class CodexSubAgent(
@JvmField val id: String,
@JvmField val name: String,
)
@Immutable
data class CodexThread(
@JvmField val id: String,
@JvmField val title: String,
@JvmField val updatedAt: Long,
@JvmField val archived: Boolean,
// TODO: Populate subAgents once Codex exposes multi-agent hierarchy data.
@JvmField val subAgents: List<CodexSubAgent> = emptyList(),
)
@Immutable
data class CodexProjectSessions(
@JvmField val path: String,
@JvmField val name: String,
@JvmField val isOpen: Boolean,
@JvmField val threads: List<CodexThread> = emptyList(),
@JvmField val isLoading: Boolean = false,
@JvmField val isPagingThreads: Boolean = false,
@JvmField val hasLoaded: Boolean = false,
@JvmField val nextThreadsCursor: String? = null,
@JvmField val errorMessage: String? = null,
@JvmField val loadMoreErrorMessage: String? = null,
)
@Immutable
data class CodexThreadPage(
@JvmField val threads: List<CodexThread>,
@JvmField val nextCursor: String?,
)
data class CodexSessionsState(
@JvmField val projects: List<CodexProjectSessions> = emptyList(),
@JvmField val lastUpdatedAt: Long? = null,
)

View File

@@ -0,0 +1,7 @@
- name: lib/agent-workbench-sessions.jar
modules:
- name: intellij.agent.workbench.plugin
contentModules:
- name: intellij.agent.workbench.codex.common
- name: intellij.agent.workbench.sessions
- name: intellij.agent.workbench

View File

@@ -0,0 +1,43 @@
### auto-generated section `build intellij.agent.workbench.plugin` start
load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
resourcegroup(
name = "plugin_resources",
srcs = glob(["resources/**/*"]),
strip_prefix = "resources"
)
jvm_library(
name = "plugin",
visibility = ["//visibility:public"],
srcs = glob([], allow_empty = True),
resources = [":plugin_resources"],
runtime_deps = [
"//plugins/agent-workbench/chat",
"//plugins/agent-workbench/codex/common",
"//plugins/agent-workbench/sessions",
]
)
jvm_library(
name = "plugin_test_lib",
module_name = "intellij.agent.workbench.plugin",
visibility = ["//visibility:public"],
srcs = glob(["testSrc/**/*.kt", "testSrc/**/*.java", "testSrc/**/*.form"], allow_empty = True),
deps = [
"@lib//:kotlin-stdlib",
"@lib//:junit5",
"@lib//:junit5Suites",
],
runtime_deps = [":plugin"]
)
### auto-generated section `build intellij.agent.workbench.plugin` end
### auto-generated section `test intellij.agent.workbench.plugin` start
load("@community//build:tests-options.bzl", "jps_test")
jps_test(
name = "plugin_test",
runtime_deps = [":plugin_test_lib"]
)
### auto-generated section `test intellij.agent.workbench.plugin` end

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/testSrc" isTestSource="true" packagePrefix="com.intellij.agent.workbench.plugin" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="intellij.agent.workbench.chat" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.agent.workbench.codex.common" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.agent.workbench.sessions" scope="RUNTIME" />
<orderEntry type="library" scope="TEST" name="kotlin-stdlib" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5Suites" level="project" />
</component>
</module>

View File

@@ -0,0 +1,12 @@
- name: lib/agent-workbench-plugin.jar
modules:
- name: intellij.agent.workbench.plugin
- name: lib/modules/intellij.agent.workbench.chat.jar
contentModules:
- name: intellij.agent.workbench.chat
- name: lib/modules/intellij.agent.workbench.codex.common.jar
contentModules:
- name: intellij.agent.workbench.codex.common
- name: lib/modules/intellij.agent.workbench.sessions.jar
contentModules:
- name: intellij.agent.workbench.sessions

View File

@@ -0,0 +1,12 @@
<!--suppress PluginXmlPluginLogo -->
<idea-plugin>
<id>com.intellij.agent.workbench</id>
<name>Agent Workbench</name>
<description>Agent Workbench - AI-based developement for IntelliJ</description>
<vendor>JetBrains</vendor>
<content namespace="jetbrains">
<module name="intellij.agent.workbench.codex.common"/>
<module name="intellij.agent.workbench.sessions"/>
<module name="intellij.agent.workbench.chat"/>
</content>
</idea-plugin>

View File

@@ -0,0 +1,12 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.plugin
import org.junit.platform.suite.api.SelectPackages
import org.junit.platform.suite.api.Suite
@Suite
@SelectPackages(
"com.intellij.agent.workbench.chat",
"com.intellij.agent.workbench.sessions",
)
class AgentWorkbenchAllTestsSuite

View File

@@ -0,0 +1,79 @@
### auto-generated section `build intellij.agent.workbench.sessions` start
load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
resourcegroup(
name = "sessions_resources",
srcs = glob(["resources/**/*"]),
strip_prefix = "resources"
)
jvm_library(
name = "sessions",
module_name = "intellij.agent.workbench.sessions",
visibility = ["//visibility:public"],
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
resources = [":sessions_resources"],
deps = [
"@lib//:kotlin-stdlib",
"//libraries/kotlinx/serialization/core",
"//platform/compose",
"//platform/core-api:core",
"//platform/core-ui",
"//platform/platform-api:ide",
"//platform/platform-impl:ide-impl",
"//platform/ide-core-impl",
"//platform/projectModel-api:projectModel",
"//platform/util",
"//platform/platform-util-io:ide-util-io",
"//plugins/agent-workbench/codex/common",
"//plugins/agent-workbench/chat",
"//libraries/jackson/jackson",
],
plugins = ["@lib//:compose-plugin"]
)
jvm_library(
name = "sessions_test_lib",
visibility = ["//visibility:public"],
srcs = glob(["testSrc/**/*.kt", "testSrc/**/*.java", "testSrc/**/*.form"], allow_empty = True),
associates = [":sessions"],
deps = [
"@lib//:kotlin-stdlib",
"//libraries/kotlinx/serialization/core",
"//platform/compose",
"//platform/compose:compose_test_lib",
"//platform/core-api:core",
"//platform/core-ui",
"//platform/platform-api:ide",
"//platform/platform-impl:ide-impl",
"//platform/ide-core-impl",
"//platform/projectModel-api:projectModel",
"//platform/util",
"//platform/platform-util-io:ide-util-io",
"//plugins/agent-workbench/codex/common",
"//plugins/agent-workbench/chat",
"//plugins/agent-workbench/chat:chat_test_lib",
"//libraries/jackson/jackson",
"//libraries/junit5",
"//libraries/junit5-params",
"//libraries/junit4",
"//libraries/compose-foundation-desktop-junit",
"//platform/jewel/int-ui/int-ui-standalone:jewel-intUi-standalone",
"//platform/testFramework",
"//platform/testFramework:testFramework_test_lib",
"//platform/testFramework/junit5",
"//platform/testFramework/junit5:junit5_test_lib",
"//libraries/assertj-core",
],
plugins = ["@lib//:compose-plugin"]
)
### auto-generated section `build intellij.agent.workbench.sessions` end
### auto-generated section `test intellij.agent.workbench.sessions` start
load("@community//build:tests-options.bzl", "jps_test")
jps_test(
name = "sessions_test",
runtime_deps = [":sessions_test_lib"]
)
### auto-generated section `test intellij.agent.workbench.sessions` end

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="5" platform="JVM 21" allPlatforms="JVM [21]" useProjectSettings="false">
<compilerSettings>
<option name="additionalArguments" value="-Xjvm-default=all -XXLanguage:+AllowEagerSupertypeAccessibilityChecks -progressive" />
</compilerSettings>
<compilerArguments>
<stringArguments>
<stringArg name="jvmTarget" arg="21" />
<stringArg name="apiVersion" arg="2.3" />
<stringArg name="languageVersion" arg="2.3" />
</stringArguments>
<arrayArguments>
<arrayArg name="pluginClasspaths">
<args>
<arg>$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.3.10-RC/kotlin-compose-compiler-plugin-2.3.10-RC.jar</arg>
<arg>$KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar</arg>
</args>
</arrayArg>
</arrayArguments>
</compilerArguments>
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="com.intellij.agent.workbench.sessions" />
<sourceFolder url="file://$MODULE_DIR$/testSrc" isTestSource="true" packagePrefix="com.intellij.agent.workbench.sessions" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />
<orderEntry type="module" module-name="intellij.libraries.kotlinx.serialization.core" />
<orderEntry type="module" module-name="intellij.platform.compose" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.platform.core.ui" />
<orderEntry type="module" module-name="intellij.platform.ide" />
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
<orderEntry type="module" module-name="intellij.platform.ide.core.impl" />
<orderEntry type="module" module-name="intellij.platform.projectModel" />
<orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="intellij.platform.ide.util.io" />
<orderEntry type="module" module-name="intellij.agent.workbench.codex.common" />
<orderEntry type="module" module-name="intellij.agent.workbench.chat" />
<orderEntry type="module" module-name="intellij.libraries.jackson" />
<orderEntry type="module" module-name="intellij.libraries.junit5" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.junit5.params" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.junit4" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.compose.foundation.desktop.junit" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.jewel.intUi.standalone" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.assertj.core" scope="TEST" />
</component>
</module>

View File

@@ -0,0 +1,34 @@
<idea-plugin>
<resource-bundle>messages.CodexSessionsBundle</resource-bundle>
<dependencies>
<!-- region Generated dependencies - run `Generate Product Layouts` to regenerate -->
<module name="intellij.agent.workbench.codex.common"/>
<module name="intellij.agent.workbench.chat"/>
<module name="intellij.platform.compose"/>
<!-- endregion -->
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<toolWindow
id="agent.workbench.sessions"
anchor="right"
icon="AllIcons.Toolwindows.ToolWindowMessages"
canCloseContents="false"
factoryClass="com.intellij.agent.workbench.sessions.CodexSessionsToolWindowFactory"/>
<advancedSetting
id="agent.workbench.chat.open.in.dedicated.frame"
default="true"
bundle="messages.CodexSessionsBundle"
groupKey="advanced.settings.agent.workbench"/>
</extensions>
<actions resource-bundle="messages.CodexSessionsBundle">
<group id="AgentWorkbenchSessions.ToolWindow.GearActions">
<reference ref="OpenFile"/>
<separator/>
<action id="AgentWorkbenchSessions.ToggleDedicatedFrame" class="com.intellij.agent.workbench.sessions.CodexSessionsDedicatedFrameToggleAction"/>
<action id="AgentWorkbenchSessions.Refresh" class="com.intellij.agent.workbench.sessions.CodexSessionsRefreshAction"/>
</group>
</actions>
</idea-plugin>

View File

@@ -0,0 +1,24 @@
toolwindow.title=Agent Threads
toolwindow.stripe.agent.workbench.sessions=Agent Threads
toolwindow.loading=Loading threads\u2026
toolwindow.empty.global=Open a project to start activity.
toolwindow.empty.project=No recent activity yet.
toolwindow.error=Unable to load sessions.
toolwindow.error.more=Unable to load more threads.
toolwindow.error.cli=Codex CLI not found. Install Codex or add it to your PATH.
toolwindow.error.retry=Retry
toolwindow.section.active=Active
toolwindow.section.archived=Archived
toolwindow.action.open=Open
toolwindow.action.new.thread=New Thread
toolwindow.action.more=More...
toolwindow.action.more.count=More ({0})
toolwindow.action.archive=Archive
toolwindow.action.unarchive=Unarchive
toolwindow.updated=Updated {0}
toolwindow.time.now=now
action.AgentWorkbenchSessions.Refresh.text=Refresh
action.AgentWorkbenchSessions.ToggleDedicatedFrame.text=Open Chat in Dedicated Frame
advanced.settings.agent.workbench=Agent Workbench
advanced.setting.agent.workbench.chat.open.in.dedicated.frame=Open Agent chat in dedicated frame
advanced.setting.agent.workbench.chat.open.in.dedicated.frame.description=When enabled, chat opens in a dedicated frame instead of the source project frame.

View File

@@ -0,0 +1,47 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.ide.RecentProjectsManager
import com.intellij.ide.RecentProjectsManagerBase
import com.intellij.ide.trustedProjects.TrustedProjects
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.project.Project
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.invariantSeparatorsPathString
internal object AgentWorkbenchDedicatedFrameProjectManager {
private val projectPath: Path by lazy {
PathManager.getConfigDir().resolve("agent-workbench-chat-frame")
}
fun dedicatedProjectPath(): String {
return projectPath.invariantSeparatorsPathString
}
fun ensureProjectPath(): Path {
val path = projectPath
Files.createDirectories(path)
return path
}
fun isDedicatedProjectPath(path: String): Boolean {
return normalizePath(path) == dedicatedProjectPath()
}
suspend fun configureProject(project: Project) {
(serviceAsync<RecentProjectsManager>() as RecentProjectsManagerBase).setProjectHidden(project, true)
TrustedProjects.setProjectTrusted(project, true)
}
private fun normalizePath(path: String): String {
return try {
Path.of(path).invariantSeparatorsPathString
}
catch (_: InvalidPathException) {
path
}
}
}

View File

@@ -0,0 +1,16 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.openapi.options.advanced.AdvancedSettings
internal const val OPEN_CHAT_IN_DEDICATED_FRAME_SETTING_ID: String = "agent.workbench.chat.open.in.dedicated.frame"
internal object CodexChatOpenModeSettings {
fun openInDedicatedFrame(): Boolean {
return AdvancedSettings.getBoolean(OPEN_CHAT_IN_DEDICATED_FRAME_SETTING_ID)
}
fun setOpenInDedicatedFrame(enabled: Boolean) {
AdvancedSettings.setBoolean(OPEN_CHAT_IN_DEDICATED_FRAME_SETTING_ID, enabled)
}
}

View File

@@ -0,0 +1,36 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
internal fun parseProjectPath(path: String?): Path? {
val trimmed = path?.trim().takeIf { !it.isNullOrEmpty() } ?: return null
return try {
Path.of(trimmed)
}
catch (_: InvalidPathException) {
null
}
}
internal fun normalizeProjectPath(projectPath: Path?): Path? {
val path = projectPath ?: return null
val fileName = path.fileName?.toString() ?: return path
val parentName = path.parent?.fileName?.toString()
val normalized = when {
fileName == ".idea" -> path.parent
parentName == ".idea" -> path.parent?.parent
fileName.endsWith(".ipr", ignoreCase = true) -> path.parent
fileName.endsWith(".iws", ignoreCase = true) -> path.parent
else -> path
}
return normalized ?: path
}
internal fun resolveProjectDirectoryFromPath(path: String): Path? {
val parsed = parseProjectPath(path) ?: return null
val normalized = normalizeProjectPath(parsed) ?: return null
return normalized.takeIf { Files.isDirectory(it) }
}

View File

@@ -0,0 +1,116 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.agent.workbench.codex.common.CodexAppServerClient
import com.intellij.agent.workbench.codex.common.CodexAppServerException
import com.intellij.agent.workbench.codex.common.CodexThread
import com.intellij.agent.workbench.codex.common.CodexThreadPage
import com.intellij.ide.RecentProjectsManagerBase
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.debug
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.guessProjectDir
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.Job
import org.jetbrains.annotations.VisibleForTesting
import java.nio.file.Files
import java.nio.file.Path
private val LOG = logger<CodexProjectSessionService>()
@Service(Service.Level.PROJECT)
internal class CodexProjectSessionService(
project: Project,
private val coroutineScope: CoroutineScope,
) {
private val workingDirectory = resolveProjectDirectory(project)
private val client = CodexAppServerClient(
coroutineScope = coroutineScope,
workingDirectory = workingDirectory,
)
init {
registerShutdownOnCancellation(coroutineScope) { client.shutdown() }
}
fun hasWorkingDirectory(): Boolean = workingDirectory != null
@Suppress("unused")
suspend fun listThreads(): List<CodexThread> {
if (workingDirectory == null) {
throw CodexAppServerException("Project directory is not available")
}
// TODO: Add archived session support and unarchive actions.
return client.listThreads(archived = false)
}
suspend fun listThreadsPage(cursor: String?, limit: Int): CodexThreadPage {
if (workingDirectory == null) {
throw CodexAppServerException("Project directory is not available")
}
// TODO: Add archived session support and unarchive actions.
return client.listThreadsPage(
archived = false,
cursor = cursor,
limit = limit,
)
}
suspend fun createThread(): CodexThread {
if (workingDirectory == null) {
throw CodexAppServerException("Project directory is not available")
}
return client.createThread()
}
}
@OptIn(InternalCoroutinesApi::class)
internal fun registerShutdownOnCancellation(scope: CoroutineScope, onShutdown: () -> Unit) {
val job = scope.coroutineContext[Job]
if (job == null) {
LOG.warn("Codex project session scope has no Job; shutdown hook not installed")
return
}
job.invokeOnCompletion(onCancelling = true, invokeImmediately = true) { _ ->
LOG.debug { "Codex project session scope cancelling; shutting down client" }
onShutdown()
}
}
private fun resolveProjectDirectory(project: Project): Path? {
val recentProjectPath = RecentProjectsManagerBase.getInstanceEx().getProjectPath(project)
val projectFilePath = project.projectFilePath
val guessedProjectDir = project.guessProjectDir()
?.takeIf { it.isInLocalFileSystem }
?.toNioPath()
return resolveProjectDirectory(
recentProjectPath = recentProjectPath,
projectFilePath = projectFilePath,
basePath = project.basePath,
guessedProjectDir = guessedProjectDir,
)
}
@VisibleForTesting
internal fun resolveProjectDirectory(
recentProjectPath: Path?,
projectFilePath: String?,
basePath: String?,
guessedProjectDir: Path?,
): Path? {
val candidates = sequenceOf(
recentProjectPath,
parseProjectPath(projectFilePath),
parseProjectPath(basePath),
guessedProjectDir,
)
for (candidate in candidates) {
val normalized = normalizeProjectPath(candidate) ?: continue
if (Files.isDirectory(normalized)) {
return normalized
}
}
return null
}

View File

@@ -0,0 +1,25 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.PropertyKey
import java.util.function.Supplier
const val CODEX_SESSIONS_BUNDLE: @NonNls String = "messages.CodexSessionsBundle"
internal object CodexSessionsBundle {
private val BUNDLE = DynamicBundle(CodexSessionsBundle::class.java, CODEX_SESSIONS_BUNDLE)
fun message(key: @PropertyKey(resourceBundle = CODEX_SESSIONS_BUNDLE) String, vararg params: Any): @Nls String {
return BUNDLE.getMessage(key, *params)
}
fun messagePointer(
key: @PropertyKey(resourceBundle = CODEX_SESSIONS_BUNDLE) String,
vararg params: Any,
): Supplier<@Nls String> {
return BUNDLE.getLazyMessage(key, *params)
}
}

View File

@@ -0,0 +1,18 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAwareToggleAction
internal class CodexSessionsDedicatedFrameToggleAction : DumbAwareToggleAction() {
override fun isSelected(e: AnActionEvent): Boolean {
return CodexChatOpenModeSettings.openInDedicatedFrame()
}
override fun setSelected(e: AnActionEvent, state: Boolean) {
CodexChatOpenModeSettings.setOpenInDedicatedFrame(state)
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}

View File

@@ -0,0 +1,20 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAwareAction
internal class CodexSessionsRefreshAction : DumbAwareAction() {
override fun actionPerformed(e: AnActionEvent) {
service<CodexSessionsService>().refresh()
}
override fun update(e: AnActionEvent) {
val isRefreshing = service<CodexSessionsService>().state.value.projects.any { it.isLoading }
e.presentation.isEnabled = !isRefreshing
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}

View File

@@ -0,0 +1,745 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@file:Suppress("ReplacePutWithAssignment")
package com.intellij.agent.workbench.sessions
import com.intellij.agent.workbench.chat.CodexChatEditorService
import com.intellij.agent.workbench.codex.common.CodexAppServerClient
import com.intellij.agent.workbench.codex.common.CodexAppServerException
import com.intellij.agent.workbench.codex.common.CodexCliNotFoundException
import com.intellij.agent.workbench.codex.common.CodexProjectSessions
import com.intellij.agent.workbench.codex.common.CodexSessionsState
import com.intellij.agent.workbench.codex.common.CodexSubAgent
import com.intellij.agent.workbench.codex.common.CodexThread
import com.intellij.agent.workbench.codex.common.CodexThreadPage
import com.intellij.ide.RecentProjectsManager
import com.intellij.ide.RecentProjectsManagerBase
import com.intellij.ide.impl.OpenProjectTask
import com.intellij.ide.impl.ProjectUtilService
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.project.ProjectManagerListener
import com.intellij.openapi.util.io.FileUtilRt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.invariantSeparatorsPathString
import kotlin.io.path.name
private val LOG = logger<CodexSessionsService>()
private const val INITIAL_VISIBLE_THREADS = 3
private const val THREADS_PAGE_SIZE = 50
private const val REFRESH_CONCURRENCY = 4
@Service(Service.Level.APP)
internal class CodexSessionsService(private val coroutineScope: CoroutineScope) {
private val refreshMutex = Mutex()
private val onDemandMutex = Mutex()
private val onDemandLoading = LinkedHashSet<String>()
private val createThreadMutex = Mutex()
private val createThreadLoading = LinkedHashSet<String>()
private val moreThreadsMutex = Mutex()
private val moreThreadsLoading = LinkedHashSet<String>()
private val treeUiState = service<CodexSessionsTreeUiStateService>()
private val mutableState = MutableStateFlow(CodexSessionsState())
val state: StateFlow<CodexSessionsState> = mutableState.asStateFlow()
init {
ApplicationManager.getApplication().messageBus.connect(coroutineScope)
.subscribe(ProjectManager.TOPIC, object : ProjectManagerListener {
@Deprecated("Deprecated in Java")
@Suppress("removal")
override fun projectOpened(project: Project) {
refresh()
}
override fun projectClosed(project: Project) {
refresh()
}
})
}
fun refresh() {
coroutineScope.launch(Dispatchers.IO) {
if (!refreshMutex.tryLock()) return@launch
try {
val entries = collectProjects()
val existingProjectsByPath = state.value.projects.associateBy { it.path }
val openProjectPaths = entries.asSequence()
.filter { it.project != null }
.map { it.path }
.toSet()
treeUiState.retainOpenProjectThreadPreviews(openProjectPaths)
val initialProjects = entries.map { entry ->
val existingProject = existingProjectsByPath[entry.path]
val cachedThreads = if (entry.project != null) treeUiState.getOpenProjectThreadPreviews(entry.path) else null
CodexProjectSessions(
path = entry.path,
name = entry.name,
isOpen = entry.project != null,
isLoading = entry.project != null,
threads = existingProject?.threads ?: cachedThreads.orEmpty(),
hasLoaded = existingProject?.hasLoaded == true || cachedThreads != null,
nextThreadsCursor = existingProject?.nextThreadsCursor,
loadMoreErrorMessage = existingProject?.loadMoreErrorMessage,
)
}
mutableState.update { it.copy(projects = initialProjects, lastUpdatedAt = System.currentTimeMillis()) }
val openEntries = entries.filter { it.project != null }
val refreshSemaphore = Semaphore(REFRESH_CONCURRENCY)
kotlinx.coroutines.coroutineScope {
openEntries
.map { entry ->
async {
refreshSemaphore.withPermit {
refreshOpenProjectThreads(entry)
}
}
}
.awaitAll()
}
}
catch (e: Throwable) {
LOG.error("Failed to load Codex sessions", e)
mutableState.update {
it.copy(
projects = it.projects.map { project ->
project.copy(
isLoading = false,
hasLoaded = project.isOpen,
errorMessage = resolveErrorMessage(e),
)
},
lastUpdatedAt = System.currentTimeMillis(),
)
}
}
finally {
refreshMutex.unlock()
}
}
}
fun openOrFocusProject(path: String) {
coroutineScope.launch {
openOrFocusProjectInternal(path)
}
}
fun openChatThread(path: String, thread: CodexThread) {
openChat(path, thread, subAgent = null)
}
fun openChatSubAgent(path: String, thread: CodexThread, subAgent: CodexSubAgent) {
openChat(path, thread, subAgent)
}
fun createAndOpenThread(path: String) {
coroutineScope.launch(Dispatchers.IO) {
val normalized = normalizePath(path)
if (!markCreateThreadLoading(normalized)) return@launch
try {
updateProject(normalized) { project ->
project.copy(isLoading = true, errorMessage = null)
}
val createdThread = createThreadForProjectPath(normalized)
updateProject(normalized) { project ->
project.copy(
isLoading = false,
hasLoaded = true,
threads = mergeThread(project.threads, createdThread),
errorMessage = null,
)
}
cacheOpenProjectThreadsIfNeeded(normalized)
openChat(normalized, createdThread, subAgent = null)
}
catch (e: Throwable) {
LOG.warn("Failed to create Codex thread for $normalized", e)
updateProject(normalized) { project ->
project.copy(
isLoading = false,
errorMessage = resolveErrorMessage(e),
)
}
}
finally {
clearCreateThreadLoading(normalized)
}
}
}
fun showAllThreadsForProject(path: String) {
coroutineScope.launch(Dispatchers.IO) {
val normalized = normalizePath(path)
val project = state.value.projects.firstOrNull { it.path == normalized } ?: return@launch
if (project.loadMoreErrorMessage != null) {
updateProject(normalized) { current ->
current.copy(loadMoreErrorMessage = null)
}
}
val visibleThreadCount = treeUiState.getVisibleThreadCount(normalized)
val loadedThreads = project.threads.sortedByDescending { it.updatedAt }
val hasMoreLoadedThreads = loadedThreads.size > visibleThreadCount
if (hasMoreLoadedThreads) {
if (treeUiState.incrementVisibleThreadCount(normalized, INITIAL_VISIBLE_THREADS)) {
mutableState.update { it.copy(lastUpdatedAt = System.currentTimeMillis()) }
}
return@launch
}
if (project.nextThreadsCursor.isNullOrBlank()) return@launch
if (!markMoreThreadsLoading(normalized)) return@launch
try {
updateProject(normalized) { current ->
current.copy(isPagingThreads = true, loadMoreErrorMessage = null)
}
val page = loadThreadsPageForProjectPath(
path = normalized,
cursor = project.nextThreadsCursor,
limit = THREADS_PAGE_SIZE,
)
updateProject(normalized) { current ->
val mergedThreads = mergeThreads(current.threads, page.threads)
current.copy(
threads = mergedThreads,
hasLoaded = true,
isPagingThreads = false,
nextThreadsCursor = page.nextCursor,
errorMessage = null,
loadMoreErrorMessage = null,
)
}
cacheOpenProjectThreadsIfNeeded(normalized)
if (treeUiState.incrementVisibleThreadCount(normalized, INITIAL_VISIBLE_THREADS)) {
mutableState.update { it.copy(lastUpdatedAt = System.currentTimeMillis()) }
}
}
catch (e: Throwable) {
LOG.warn("Failed to load additional Codex sessions for $normalized", e)
updateProject(normalized) { current ->
current.copy(
isPagingThreads = false,
loadMoreErrorMessage = resolveLoadMoreErrorMessage(e),
)
}
}
finally {
clearMoreThreadsLoading(normalized)
}
}
}
fun loadProjectThreadsOnDemand(path: String) {
coroutineScope.launch(Dispatchers.IO) {
val normalized = normalizePath(path)
if (!markOnDemandLoading(normalized)) return@launch
try {
updateProject(normalized) { project ->
project.copy(isLoading = true, errorMessage = null)
}
val result = try {
val page = loadInitialThreadsForProjectPath(normalized)
LoadedResult(
threads = page.threads,
nextCursor = page.nextCursor,
errorMessage = null,
)
}
catch (e: Throwable) {
LOG.warn("Failed to load Codex sessions for $normalized", e)
LoadedResult(
threads = emptyList(),
nextCursor = null,
errorMessage = resolveErrorMessage(e),
)
}
updateProject(normalized) { project ->
project.copy(
isLoading = false,
hasLoaded = true,
threads = result.threads,
nextThreadsCursor = result.nextCursor,
errorMessage = result.errorMessage,
loadMoreErrorMessage = null,
)
}
}
finally {
clearOnDemandLoading(normalized)
}
}
}
private suspend fun refreshOpenProjectThreads(entry: ProjectEntry) {
val result = try {
val page = loadInitialThreadsForProjectPath(entry.path)
LoadedResult(
threads = page.threads,
nextCursor = page.nextCursor,
errorMessage = null,
)
}
catch (e: Throwable) {
LOG.warn("Failed to load Codex sessions for ${entry.path}", e)
LoadedResult(
threads = emptyList(),
nextCursor = null,
errorMessage = resolveErrorMessage(e),
)
}
updateProject(entry.path) { project ->
val refreshSucceeded = result.errorMessage == null
val nextThreads = if (refreshSucceeded) result.threads else project.threads
val nextCursor = if (refreshSucceeded) result.nextCursor else project.nextThreadsCursor
project.copy(
isLoading = false,
hasLoaded = true,
isPagingThreads = false,
threads = nextThreads,
nextThreadsCursor = nextCursor,
errorMessage = result.errorMessage,
loadMoreErrorMessage = if (refreshSucceeded) null else project.loadMoreErrorMessage,
)
}
if (result.errorMessage == null) {
treeUiState.setOpenProjectThreadPreviews(entry.path, result.threads)
}
}
private suspend fun loadThreadsPageForProjectPath(path: String, cursor: String?, limit: Int): CodexThreadPage {
val openProject = findOpenProject(path)
if (openProject != null) {
val service = openProject.getService(CodexProjectSessionService::class.java)
if (service == null || !service.hasWorkingDirectory()) {
throw CodexAppServerException("Project directory is not available")
}
return service.listThreadsPage(cursor = cursor, limit = limit)
}
val workingDirectory = resolveProjectDirectoryFromPath(path)
?: throw CodexAppServerException("Project directory is not available")
val client = CodexAppServerClient(coroutineScope = coroutineScope, workingDirectory = workingDirectory)
try {
return client.listThreadsPage(
archived = false,
cursor = cursor,
limit = limit,
)
}
finally {
client.shutdown()
}
}
private suspend fun loadInitialThreadsForProjectPath(path: String): CodexThreadPage {
val initialPage = loadThreadsPageForProjectPath(
path = path,
cursor = null,
limit = THREADS_PAGE_SIZE,
)
return seedInitialVisibleThreads(
initialPage = initialPage,
minimumVisibleThreads = INITIAL_VISIBLE_THREADS,
loadNextPage = { cursor ->
loadThreadsPageForProjectPath(
path = path,
cursor = cursor,
limit = THREADS_PAGE_SIZE,
)
},
)
}
private fun resolveErrorMessage(t: Throwable): String {
return when (t) {
is CodexCliNotFoundException -> CodexSessionsBundle.message("toolwindow.error.cli")
else -> CodexSessionsBundle.message("toolwindow.error")
}
}
private fun resolveLoadMoreErrorMessage(t: Throwable): String {
return when (t) {
is CodexCliNotFoundException -> CodexSessionsBundle.message("toolwindow.error.cli")
else -> CodexSessionsBundle.message("toolwindow.error.more")
}
}
private suspend fun createThreadForProjectPath(path: String): CodexThread {
val openProject = findOpenProject(path)
if (openProject != null) {
val service = openProject.serviceAsync<CodexProjectSessionService>()
if (!service.hasWorkingDirectory()) {
throw CodexAppServerException("Project directory is not available")
}
return service.createThread()
}
val workingDirectory = resolveProjectDirectoryFromPath(path)
?: throw CodexAppServerException("Project directory is not available")
val client = CodexAppServerClient(coroutineScope = coroutineScope, workingDirectory = workingDirectory)
try {
return client.createThread()
}
finally {
client.shutdown()
}
}
private fun mergeThread(threads: List<CodexThread>, thread: CodexThread): List<CodexThread> {
return mergeThreads(threads, listOf(thread))
}
private fun mergeThreads(currentThreads: List<CodexThread>, additionalThreads: List<CodexThread>): List<CodexThread> {
return mergeCodexThreads(currentThreads, additionalThreads)
}
private fun cacheOpenProjectThreadsIfNeeded(path: String) {
val project = state.value.projects.firstOrNull { it.path == path } ?: return
if (!project.isOpen) return
treeUiState.setOpenProjectThreadPreviews(path, project.threads)
}
private suspend fun openOrFocusProjectInternal(path: String) {
val normalized = normalizePath(path)
val openProject = findOpenProject(normalized)
if (openProject != null) {
withContext(Dispatchers.EDT) {
ProjectUtilService.getInstance(openProject).focusProjectWindow()
}
return
}
val manager = RecentProjectsManager.getInstance() as? RecentProjectsManagerBase ?: return
val projectPath = try {
Path.of(path)
}
catch (_: InvalidPathException) {
return
}
manager.openProject(projectFile = projectPath, options = OpenProjectTask())
}
private fun openChat(path: String, thread: CodexThread, subAgent: CodexSubAgent?) {
coroutineScope.launch {
val normalized = normalizePath(path)
val openProject = findOpenProject(normalized)
when (resolveChatOpenRoute(
openInDedicatedFrame = CodexChatOpenModeSettings.openInDedicatedFrame(),
hasOpenSourceProject = openProject != null,
)) {
CodexChatOpenRoute.DedicatedFrame -> {
openChatInDedicatedFrame(normalized, thread, subAgent)
return@launch
}
CodexChatOpenRoute.CurrentProject -> {
openChatInProject(openProject ?: return@launch, normalized, thread, subAgent)
return@launch
}
CodexChatOpenRoute.OpenSourceProject -> {
val manager = RecentProjectsManager.getInstance() as? RecentProjectsManagerBase ?: return@launch
val projectPath = try {
Path.of(path)
}
catch (_: InvalidPathException) {
return@launch
}
val connection = ApplicationManager.getApplication().messageBus.connect(coroutineScope)
connection.subscribe(ProjectManager.TOPIC, object : ProjectManagerListener {
@Deprecated("Deprecated in Java")
@Suppress("removal")
override fun projectOpened(project: Project) {
if (resolveProjectPath(manager, project) != normalized) return
coroutineScope.launch {
openChatInProject(project, normalized, thread, subAgent)
connection.disconnect()
}
}
})
manager.openProject(projectFile = projectPath, options = OpenProjectTask())
}
}
}
}
private suspend fun openChatInDedicatedFrame(path: String, thread: CodexThread, subAgent: CodexSubAgent?) {
val dedicatedProjectPath = AgentWorkbenchDedicatedFrameProjectManager.dedicatedProjectPath()
val openProject = findOpenProject(dedicatedProjectPath)
if (openProject != null) {
AgentWorkbenchDedicatedFrameProjectManager.configureProject(openProject)
openChatInProject(openProject, path, thread, subAgent)
return
}
val manager = RecentProjectsManager.getInstance() as? RecentProjectsManagerBase ?: return
val dedicatedProjectDir = try {
AgentWorkbenchDedicatedFrameProjectManager.ensureProjectPath()
}
catch (e: Throwable) {
LOG.warn("Failed to prepare dedicated chat frame project", e)
return
}
val connection = ApplicationManager.getApplication().messageBus.connect(coroutineScope)
connection.subscribe(ProjectManager.TOPIC, object : ProjectManagerListener {
@Deprecated("Deprecated in Java")
@Suppress("removal")
override fun projectOpened(project: Project) {
if (resolveProjectPath(manager, project) != dedicatedProjectPath) return
coroutineScope.launch {
AgentWorkbenchDedicatedFrameProjectManager.configureProject(project)
openChatInProject(project, path, thread, subAgent)
connection.disconnect()
}
}
})
manager.openProject(projectFile = dedicatedProjectDir, options = OpenProjectTask(forceOpenInNewFrame = true))
}
private suspend fun openChatInProject(
project: Project,
projectPath: String,
thread: CodexThread,
subAgent: CodexSubAgent?,
) {
withContext(Dispatchers.EDT) {
project.service<CodexChatEditorService>().openChat(
projectPath = projectPath,
threadId = thread.id,
threadTitle = thread.title,
subAgentId = subAgent?.id,
)
ProjectUtilService.getInstance(project).focusProjectWindow()
}
}
private fun updateProject(path: String, update: (CodexProjectSessions) -> CodexProjectSessions) {
mutableState.update { state ->
val next = state.projects.map { project ->
if (project.path == path) update(project) else project
}
state.copy(projects = next, lastUpdatedAt = System.currentTimeMillis())
}
}
private suspend fun markOnDemandLoading(path: String): Boolean {
return onDemandMutex.withLock {
val project = state.value.projects.firstOrNull { it.path == path } ?: return@withLock false
if (project.isOpen || project.isLoading || project.hasLoaded) return@withLock false
if (!onDemandLoading.add(path)) return@withLock false
true
}
}
private suspend fun markCreateThreadLoading(path: String): Boolean {
return createThreadMutex.withLock {
val project = state.value.projects.firstOrNull { it.path == path } ?: return@withLock false
if (project.isLoading) return@withLock false
if (!createThreadLoading.add(path)) return@withLock false
true
}
}
private suspend fun markMoreThreadsLoading(path: String): Boolean {
return moreThreadsMutex.withLock {
val project = state.value.projects.firstOrNull { it.path == path } ?: return@withLock false
if (project.isPagingThreads || project.nextThreadsCursor.isNullOrBlank()) return@withLock false
if (!moreThreadsLoading.add(path)) return@withLock false
true
}
}
private suspend fun clearOnDemandLoading(path: String) {
onDemandMutex.withLock {
onDemandLoading.remove(path)
}
}
private suspend fun clearCreateThreadLoading(path: String) {
createThreadMutex.withLock {
createThreadLoading.remove(path)
}
}
private suspend fun clearMoreThreadsLoading(path: String) {
moreThreadsMutex.withLock {
moreThreadsLoading.remove(path)
}
}
}
internal enum class CodexChatOpenRoute {
DedicatedFrame,
CurrentProject,
OpenSourceProject,
}
internal fun resolveChatOpenRoute(
openInDedicatedFrame: Boolean,
hasOpenSourceProject: Boolean,
): CodexChatOpenRoute {
if (openInDedicatedFrame) return CodexChatOpenRoute.DedicatedFrame
if (hasOpenSourceProject) return CodexChatOpenRoute.CurrentProject
return CodexChatOpenRoute.OpenSourceProject
}
private fun collectProjects(): List<ProjectEntry> {
val manager = RecentProjectsManager.getInstance() as? RecentProjectsManagerBase ?: return emptyList()
val dedicatedProjectPath = AgentWorkbenchDedicatedFrameProjectManager.dedicatedProjectPath()
val openProjects = ProjectManager.getInstance().openProjects
val openByPath = LinkedHashMap<String, Project>()
for (project in openProjects) {
val path = manager.getProjectPath(project)?.invariantSeparatorsPathString
?: project.basePath?.let(::normalizePath)
?: continue
if (path == dedicatedProjectPath || AgentWorkbenchDedicatedFrameProjectManager.isDedicatedProjectPath(path)) {
continue
}
openByPath.put(path, project)
}
val seen = LinkedHashSet<String>()
val entries = mutableListOf<ProjectEntry>()
for (path in manager.getRecentPaths()) {
val normalized = normalizePath(path)
if (normalized == dedicatedProjectPath || AgentWorkbenchDedicatedFrameProjectManager.isDedicatedProjectPath(normalized)) continue
if (!seen.add(normalized)) continue
entries.add(
ProjectEntry(
path = normalized,
name = resolveProjectName(manager, normalized, openByPath[normalized]),
project = openByPath[normalized],
)
)
}
for ((path, project) in openByPath) {
if (!seen.add(path)) continue
entries.add(
ProjectEntry(
path = path,
name = resolveProjectName(manager, path, project),
project = project,
)
)
}
return entries
}
private fun resolveProjectName(
manager: RecentProjectsManagerBase,
path: String,
project: Project?,
): String {
val displayName = manager.getDisplayName(path).takeIf { !it.isNullOrBlank() }
if (displayName != null) return displayName
val projectName = manager.getProjectName(path)
if (projectName.isNotBlank()) return projectName
if (project != null) return project.name
val fileName = try {
Path.of(path).name
}
catch (_: InvalidPathException) {
null
}
return fileName ?: FileUtilRt.toSystemDependentName(path)
}
private fun findOpenProject(path: String): Project? {
val manager = RecentProjectsManager.getInstance() as? RecentProjectsManagerBase ?: return null
val normalized = normalizePath(path)
return ProjectManager.getInstance().openProjects.firstOrNull { project ->
resolveProjectPath(manager, project) == normalized
}
}
private fun resolveProjectPath(manager: RecentProjectsManagerBase, project: Project): String? {
return manager.getProjectPath(project)?.invariantSeparatorsPathString
?: project.basePath?.let(::normalizePath)
}
private fun normalizePath(path: String): String {
return try {
Path.of(path).invariantSeparatorsPathString
}
catch (_: InvalidPathException) {
path
}
}
private data class ProjectEntry(
val path: String,
val name: String,
val project: Project?,
)
private data class LoadedResult(
val threads: List<CodexThread>,
val nextCursor: String?,
val errorMessage: String?,
)
internal suspend fun seedInitialVisibleThreads(
initialPage: CodexThreadPage,
minimumVisibleThreads: Int,
loadNextPage: suspend (cursor: String) -> CodexThreadPage,
): CodexThreadPage {
if (minimumVisibleThreads <= 0) return initialPage
var mergedThreads = mergeCodexThreads(emptyList(), initialPage.threads)
var nextCursor = initialPage.nextCursor
val visitedCursors = LinkedHashSet<String>()
while (mergedThreads.size < minimumVisibleThreads) {
val cursor = nextCursor?.takeIf { it.isNotBlank() } ?: break
if (!visitedCursors.add(cursor)) break
val page = loadNextPage(cursor)
val previousSize = mergedThreads.size
mergedThreads = mergeCodexThreads(mergedThreads, page.threads)
nextCursor = page.nextCursor
if (mergedThreads.size == previousSize && nextCursor == cursor) break
}
return CodexThreadPage(
threads = mergedThreads,
nextCursor = nextCursor,
)
}
private fun mergeCodexThreads(currentThreads: List<CodexThread>, additionalThreads: List<CodexThread>): List<CodexThread> {
val merged = LinkedHashMap<String, CodexThread>()
for (thread in currentThreads) {
merged[thread.id] = thread
}
for (thread in additionalThreads) {
val existing = merged[thread.id]
if (existing == null || thread.updatedAt >= existing.updatedAt) {
merged[thread.id] = thread
}
}
return merged.values.sortedByDescending { it.updatedAt }
}

View File

@@ -0,0 +1,39 @@
package com.intellij.agent.workbench.sessions
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.typography
internal object CodexSessionsTextStyles {
// Use theme typography so sizes track IntelliJ Platform defaults.
@Composable
fun projectTitle(): TextStyle {
return JewelTheme.typography.h4TextStyle
}
@Composable
fun threadTitle(): TextStyle {
return JewelTheme.typography.regular
}
@Composable
fun threadTime(): TextStyle {
return JewelTheme.typography.small
}
@Composable
fun subAgentTitle(): TextStyle {
return JewelTheme.typography.small
}
@Composable
fun emptyState(): TextStyle {
return JewelTheme.typography.small
}
@Composable
fun error(): TextStyle {
return JewelTheme.typography.small
}
}

View File

@@ -0,0 +1,98 @@
package com.intellij.agent.workbench.sessions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.intellij.agent.workbench.codex.common.CodexSessionsState
import com.intellij.agent.workbench.codex.common.CodexSubAgent
import com.intellij.agent.workbench.codex.common.CodexThread
import com.intellij.openapi.components.service
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.Text
@Composable
internal fun codexSessionsToolWindow() {
val service = remember { service<CodexSessionsService>() }
val treeUiState = remember { service<CodexSessionsTreeUiStateService>() }
val state by service.state.collectAsState()
LaunchedEffect(Unit) {
service.refresh()
}
codexSessionsToolWindowContent(
state = state,
onRefresh = { service.refresh() },
onOpenProject = { service.openOrFocusProject(it) },
onProjectExpanded = { service.loadProjectThreadsOnDemand(it) },
onCreateThread = { service.createAndOpenThread(it) },
onShowMoreThreads = { service.showAllThreadsForProject(it) },
onOpenThread = { path, thread -> service.openChatThread(path, thread) },
onOpenSubAgent = { path, thread, subAgent -> service.openChatSubAgent(path, thread, subAgent) },
treeUiState = treeUiState,
)
}
@Composable
internal fun codexSessionsToolWindowContent(
state: CodexSessionsState,
onRefresh: () -> Unit,
onOpenProject: (String) -> Unit,
onProjectExpanded: (String) -> Unit = {},
onCreateThread: (String) -> Unit = {},
onShowMoreThreads: (String) -> Unit = {},
onOpenThread: (String, CodexThread) -> Unit = { _, _ -> },
onOpenSubAgent: (String, CodexThread, CodexSubAgent) -> Unit = { _, _, _ -> },
treeUiState: SessionsTreeUiState? = null,
nowProvider: () -> Long = { System.currentTimeMillis() },
) {
val effectiveTreeUiState = treeUiState ?: remember { InMemorySessionsTreeUiState() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
when {
state.projects.isEmpty() -> emptyState(isLoading = state.lastUpdatedAt == null)
else -> sessionTree(
projects = state.projects,
onRefresh = onRefresh,
onOpenProject = onOpenProject,
onProjectExpanded = onProjectExpanded,
onCreateThread = onCreateThread,
onShowMoreThreads = onShowMoreThreads,
onOpenThread = onOpenThread,
onOpenSubAgent = onOpenSubAgent,
treeUiState = effectiveTreeUiState,
nowProvider = nowProvider,
)
}
}
}
@Composable
private fun emptyState(isLoading: Boolean) {
val messageKey = if (isLoading) "toolwindow.loading" else "toolwindow.empty.global"
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = CodexSessionsBundle.message(messageKey),
color = JewelTheme.globalColors.text.disabled,
style = CodexSessionsTextStyles.emptyState(),
)
}
}

View File

@@ -0,0 +1,25 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.content.ContentFactory
import org.jetbrains.jewel.bridge.compose
internal class CodexSessionsToolWindowFactory : ToolWindowFactory, DumbAware {
override fun init(toolWindow: ToolWindow) {
toolWindow.setStripeTitle(CodexSessionsBundle.message("toolwindow.title"))
}
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
toolWindow.title = CodexSessionsBundle.message("toolwindow.title")
val contentFactory = ContentFactory.getInstance()
val panel = compose { codexSessionsToolWindow() }
val content = contentFactory.createContent(panel, null, false)
toolWindow.contentManager.addContent(content)
toolWindow.setAdditionalGearActions(ActionUtil.getActionGroup("AgentWorkbenchSessions.ToolWindow.GearActions"))
}
}

View File

@@ -0,0 +1,260 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.agent.workbench.codex.common.CodexThread
import com.intellij.openapi.components.SerializablePersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.SettingsCategory
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import kotlinx.serialization.Serializable
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.invariantSeparatorsPathString
internal interface SessionsTreeUiState {
fun isProjectCollapsed(path: String): Boolean
fun setProjectCollapsed(path: String, collapsed: Boolean): Boolean
fun getVisibleThreadCount(path: String): Int
fun incrementVisibleThreadCount(path: String, delta: Int): Boolean
fun resetVisibleThreadCount(path: String): Boolean
fun getOpenProjectThreadPreviews(path: String): List<CodexThread>?
fun setOpenProjectThreadPreviews(path: String, threads: List<CodexThread>): Boolean
fun retainOpenProjectThreadPreviews(paths: Set<String>): Boolean
}
internal const val DEFAULT_VISIBLE_THREAD_COUNT: Int = 3
internal const val OPEN_PROJECT_THREAD_CACHE_LIMIT: Int = 10
internal class InMemorySessionsTreeUiState : SessionsTreeUiState {
private val collapsedProjectPaths = LinkedHashSet<String>()
private val visibleThreadCountByProject = LinkedHashMap<String, Int>()
private val openProjectThreadPreviewsByProject = LinkedHashMap<String, List<CodexThread>>()
override fun isProjectCollapsed(path: String): Boolean {
return normalizeSessionsProjectPath(path) in collapsedProjectPaths
}
override fun setProjectCollapsed(path: String, collapsed: Boolean): Boolean {
val normalized = normalizeSessionsProjectPath(path)
return if (collapsed) {
collapsedProjectPaths.add(normalized)
}
else {
collapsedProjectPaths.remove(normalized)
}
}
override fun getVisibleThreadCount(path: String): Int {
val normalized = normalizeSessionsProjectPath(path)
return visibleThreadCountByProject[normalized] ?: DEFAULT_VISIBLE_THREAD_COUNT
}
override fun incrementVisibleThreadCount(path: String, delta: Int): Boolean {
if (delta <= 0) return false
val normalized = normalizeSessionsProjectPath(path)
val current = visibleThreadCountByProject[normalized] ?: DEFAULT_VISIBLE_THREAD_COUNT
val updated = normalizeVisibleThreadCount(current + delta)
if (updated == current) return false
return if (updated == DEFAULT_VISIBLE_THREAD_COUNT) {
visibleThreadCountByProject.remove(normalized) != null
}
else {
visibleThreadCountByProject.put(normalized, updated) != updated
}
}
override fun resetVisibleThreadCount(path: String): Boolean {
return visibleThreadCountByProject.remove(normalizeSessionsProjectPath(path)) != null
}
override fun getOpenProjectThreadPreviews(path: String): List<CodexThread>? {
return openProjectThreadPreviewsByProject[normalizeSessionsProjectPath(path)]
}
override fun setOpenProjectThreadPreviews(path: String, threads: List<CodexThread>): Boolean {
val normalizedPath = normalizeSessionsProjectPath(path)
val normalizedThreads = normalizeOpenProjectThreadPreviewList(threads)
val current = openProjectThreadPreviewsByProject[normalizedPath]
if (current == normalizedThreads) return false
openProjectThreadPreviewsByProject[normalizedPath] = normalizedThreads
return true
}
override fun retainOpenProjectThreadPreviews(paths: Set<String>): Boolean {
val normalizedPaths = paths.mapTo(LinkedHashSet()) { normalizeSessionsProjectPath(it) }
var changed = false
val iterator = openProjectThreadPreviewsByProject.keys.iterator()
while (iterator.hasNext()) {
val key = iterator.next()
if (key !in normalizedPaths) {
iterator.remove()
changed = true
}
}
return changed
}
}
@Service(Service.Level.APP)
@State(name = "CodexSessionsTreeUiState", storages = [Storage("other.xml")], category = SettingsCategory.TOOLS)
internal class CodexSessionsTreeUiStateService
: SerializablePersistentStateComponent<CodexSessionsTreeUiStateService.SessionsTreeUiStateState>(SessionsTreeUiStateState()),
SessionsTreeUiState {
override fun isProjectCollapsed(path: String): Boolean {
return normalizeSessionsProjectPath(path) in state.collapsedProjectPaths
}
override fun setProjectCollapsed(path: String, collapsed: Boolean): Boolean {
return updateNormalizedPathSet(
path = path,
includePath = collapsed,
currentSet = { it.collapsedProjectPaths },
setUpdated = { current, updated -> current.copy(collapsedProjectPaths = updated) },
)
}
override fun getVisibleThreadCount(path: String): Int {
val normalized = normalizeSessionsProjectPath(path)
return state.visibleThreadCountByProject[normalized] ?: DEFAULT_VISIBLE_THREAD_COUNT
}
override fun incrementVisibleThreadCount(path: String, delta: Int): Boolean {
if (delta <= 0) return false
val normalizedPath = normalizeSessionsProjectPath(path)
val current = state.visibleThreadCountByProject[normalizedPath] ?: DEFAULT_VISIBLE_THREAD_COUNT
val updated = normalizeVisibleThreadCount(current + delta)
return setVisibleThreadCountInternal(normalizedPath, updated)
}
override fun resetVisibleThreadCount(path: String): Boolean {
val normalizedPath = normalizeSessionsProjectPath(path)
return setVisibleThreadCountInternal(normalizedPath, DEFAULT_VISIBLE_THREAD_COUNT)
}
override fun getOpenProjectThreadPreviews(path: String): List<CodexThread>? {
val normalizedPath = normalizeSessionsProjectPath(path)
val previews = state.openProjectThreadPreviewsByProject[normalizedPath] ?: return null
return previews.map { preview ->
CodexThread(
id = preview.id,
title = preview.title,
updatedAt = preview.updatedAt,
archived = false,
)
}
}
override fun setOpenProjectThreadPreviews(path: String, threads: List<CodexThread>): Boolean {
val normalizedPath = normalizeSessionsProjectPath(path)
val normalizedPreviews = normalizeOpenProjectThreadPreviewList(threads)
.map { thread -> ThreadPreviewState(id = thread.id, title = thread.title, updatedAt = thread.updatedAt) }
val current = state.openProjectThreadPreviewsByProject[normalizedPath]
if (current == normalizedPreviews) {
return false
}
updateState { currentState ->
val updated = currentState.openProjectThreadPreviewsByProject.toMutableMap()
updated[normalizedPath] = normalizedPreviews
currentState.copy(openProjectThreadPreviewsByProject = updated)
}
return true
}
override fun retainOpenProjectThreadPreviews(paths: Set<String>): Boolean {
val normalizedPaths = paths.mapTo(LinkedHashSet()) { normalizeSessionsProjectPath(it) }
val current = state.openProjectThreadPreviewsByProject
if (current.keys.all { it in normalizedPaths }) {
return false
}
updateState { currentState ->
val filtered = currentState.openProjectThreadPreviewsByProject
.filterKeys { it in normalizedPaths }
currentState.copy(openProjectThreadPreviewsByProject = filtered)
}
return true
}
private fun updateNormalizedPathSet(
path: String,
includePath: Boolean,
currentSet: (SessionsTreeUiStateState) -> Set<String>,
setUpdated: (SessionsTreeUiStateState, Set<String>) -> SessionsTreeUiStateState,
): Boolean {
val normalized = normalizeSessionsProjectPath(path)
if ((normalized in currentSet(state)) == includePath) {
return false
}
updateState { current ->
val updated = currentSet(current).toMutableSet()
if (includePath) {
updated.add(normalized)
}
else {
updated.remove(normalized)
}
setUpdated(current, updated)
}
return true
}
private fun setVisibleThreadCountInternal(normalizedPath: String, visibleCount: Int): Boolean {
val normalizedCount = normalizeVisibleThreadCount(visibleCount)
val currentCount = state.visibleThreadCountByProject[normalizedPath] ?: DEFAULT_VISIBLE_THREAD_COUNT
if (currentCount == normalizedCount) {
return false
}
updateState { current ->
val updated = current.visibleThreadCountByProject.toMutableMap()
if (normalizedCount == DEFAULT_VISIBLE_THREAD_COUNT) {
updated.remove(normalizedPath)
}
else {
updated[normalizedPath] = normalizedCount
}
current.copy(visibleThreadCountByProject = updated)
}
return true
}
@Serializable
internal data class SessionsTreeUiStateState(
@JvmField val collapsedProjectPaths: Set<String> = emptySet(),
@JvmField val visibleThreadCountByProject: Map<String, Int> = emptyMap(),
@JvmField val openProjectThreadPreviewsByProject: Map<String, List<ThreadPreviewState>> = emptyMap(),
)
@Serializable
internal data class ThreadPreviewState(
@JvmField val id: String,
@JvmField val title: String,
@JvmField val updatedAt: Long,
)
}
private fun normalizeVisibleThreadCount(value: Int): Int {
return value.coerceAtLeast(DEFAULT_VISIBLE_THREAD_COUNT)
}
private fun normalizeOpenProjectThreadPreviewList(threads: List<CodexThread>): List<CodexThread> {
return threads
.sortedByDescending { it.updatedAt }
.take(OPEN_PROJECT_THREAD_CACHE_LIMIT)
}
internal fun normalizeSessionsProjectPath(path: String): String {
return try {
Path.of(path).invariantSeparatorsPathString
}
catch (_: InvalidPathException) {
path
}
}

View File

@@ -0,0 +1,255 @@
package com.intellij.agent.workbench.sessions
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.intellij.agent.workbench.codex.common.CodexProjectSessions
import com.intellij.agent.workbench.codex.common.CodexSubAgent
import com.intellij.agent.workbench.codex.common.CodexThread
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
import org.jetbrains.jewel.foundation.lazy.tree.Tree
import org.jetbrains.jewel.foundation.lazy.tree.buildTree
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.SpeedSearchArea
import org.jetbrains.jewel.ui.component.search.SpeedSearchableTree
import org.jetbrains.jewel.ui.component.styling.LazyTreeMetrics
import org.jetbrains.jewel.ui.component.styling.LazyTreeStyle
import org.jetbrains.jewel.ui.theme.treeStyle
private const val PROJECT_CLICK_SUPPRESSION_MS = 500L
@OptIn(ExperimentalJewelApi::class)
@Composable
internal fun sessionTree(
projects: List<CodexProjectSessions>,
onRefresh: () -> Unit,
onOpenProject: (String) -> Unit,
onProjectExpanded: (String) -> Unit,
onCreateThread: (String) -> Unit,
onShowMoreThreads: (String) -> Unit,
onOpenThread: (String, CodexThread) -> Unit,
onOpenSubAgent: (String, CodexThread, CodexSubAgent) -> Unit,
treeUiState: SessionsTreeUiState,
nowProvider: () -> Long,
) {
val stateHolder = rememberSessionTreeStateHolder(
onProjectExpanded = { path ->
treeUiState.setProjectCollapsed(path, collapsed = false)
onProjectExpanded(path)
},
onProjectCollapsed = { path ->
treeUiState.setProjectCollapsed(path, collapsed = true)
},
)
val treeState = stateHolder.treeState
val defaultOpenProjects = projects
.map { SessionTreeId.Project(it.path) }
.filterNot { treeUiState.isProjectCollapsed(it.path) }
LaunchedEffect(defaultOpenProjects) {
stateHolder.applyDefaultOpenProjects(defaultOpenProjects)
}
val visibleThreadCountByProject = projects
.associate { project -> project.path to treeUiState.getVisibleThreadCount(project.path) }
val tree = remember(projects, visibleThreadCountByProject) {
buildSessionTree(
projects = projects,
visibleThreadCountByProject = visibleThreadCountByProject,
)
}
var suppressedProjectClick by remember { mutableStateOf<SuppressedProjectClick?>(null) }
fun suppressNextProjectClick(path: String) {
suppressedProjectClick = SuppressedProjectClick(
path = path,
expiresAt = System.currentTimeMillis() + PROJECT_CLICK_SUPPRESSION_MS,
)
}
fun shouldSuppressProjectClick(path: String): Boolean {
val suppression = suppressedProjectClick ?: return false
if (System.currentTimeMillis() > suppression.expiresAt) {
suppressedProjectClick = null
return false
}
if (suppression.path != path) {
return false
}
suppressedProjectClick = null
return true
}
val treeStyle = run {
val baseStyle = JewelTheme.treeStyle
val metrics = baseStyle.metrics
// Reduce indent to offset the chevron width so depth feels like a single step.
val indentSize = (metrics.indentSize - metrics.simpleListItemMetrics.iconTextGap)
.coerceAtLeast(metrics.indentSize * 0.5f)
LazyTreeStyle(
colors = baseStyle.colors,
metrics = LazyTreeMetrics(
indentSize = indentSize,
elementMinHeight = metrics.elementMinHeight,
chevronContentGap = metrics.chevronContentGap,
simpleListItemMetrics = metrics.simpleListItemMetrics,
),
icons = baseStyle.icons,
)
}
SpeedSearchArea(Modifier.fillMaxSize()) {
SpeedSearchableTree(
tree = tree,
modifier = Modifier.fillMaxSize().focusable(),
treeState = treeState,
nodeText = { element -> sessionTreeNodeText(element.data) },
style = treeStyle,
onElementClick = { element ->
when (val node = element.data) {
is SessionTreeNode.Project -> {
if (!shouldSuppressProjectClick(node.project.path)) {
onOpenProject(node.project.path)
}
}
is SessionTreeNode.Thread -> onOpenThread(node.project.path, node.thread)
is SessionTreeNode.SubAgent -> onOpenSubAgent(node.project.path, node.thread, node.subAgent)
is SessionTreeNode.MoreThreads -> onShowMoreThreads(node.projectPath)
is SessionTreeNode.MoreError -> Unit
is SessionTreeNode.Error -> Unit
is SessionTreeNode.Empty -> Unit
}
},
onElementDoubleClick = {},
onSelectionChange = {},
) { element ->
sessionTreeNodeContent(
element = element,
onOpenProject = onOpenProject,
onCreateThread = { path ->
suppressNextProjectClick(path)
onCreateThread(path)
},
onShowMoreThreads = onShowMoreThreads,
onRefresh = onRefresh,
nowProvider = nowProvider,
)
}
}
}
private data class SuppressedProjectClick(
val path: String,
val expiresAt: Long,
)
private fun buildSessionTree(
projects: List<CodexProjectSessions>,
visibleThreadCountByProject: Map<String, Int>,
): Tree<SessionTreeNode> =
buildTree {
projects.forEach { project ->
val projectId = SessionTreeId.Project(project.path)
addNode(
data = SessionTreeNode.Project(project),
id = projectId,
) {
val sortedThreads = project.threads.sortedByDescending { it.updatedAt }
val visibleThreadCount = visibleThreadCountByProject[project.path] ?: DEFAULT_VISIBLE_THREAD_COUNT
val visibleThreads = sortedThreads.take(visibleThreadCount)
visibleThreads.forEach { thread ->
val threadId = SessionTreeId.Thread(project.path, thread.id)
if (thread.subAgents.isNotEmpty()) {
addNode(
data = SessionTreeNode.Thread(project, thread),
id = threadId,
) {
thread.subAgents.forEach { subAgent ->
addLeaf(
data = SessionTreeNode.SubAgent(project, thread, subAgent),
id = SessionTreeId.SubAgent(project.path, thread.id, subAgent.id),
)
}
}
}
else {
addLeaf(
data = SessionTreeNode.Thread(project, thread),
id = threadId,
)
}
}
val errorMessage = project.errorMessage
if (errorMessage != null) {
addLeaf(
data = SessionTreeNode.Error(project, errorMessage),
id = SessionTreeId.Error(project.path),
)
}
else if (project.hasLoaded && !project.isLoading && sortedThreads.isEmpty()) {
addLeaf(
data = SessionTreeNode.Empty(project, CodexSessionsBundle.message("toolwindow.empty.project")),
id = SessionTreeId.Empty(project.path),
)
}
val loadMoreErrorMessage = project.loadMoreErrorMessage
if (loadMoreErrorMessage != null && sortedThreads.isNotEmpty()) {
addLeaf(
data = SessionTreeNode.MoreError(project.path, loadMoreErrorMessage),
id = SessionTreeId.MoreError(project.path),
)
}
val hiddenThreadsCount = sortedThreads.size - visibleThreads.size
val hasLoadMoreCursor = !project.nextThreadsCursor.isNullOrBlank()
val canShowCursorDrivenMore = hasLoadMoreCursor && sortedThreads.size >= DEFAULT_VISIBLE_THREAD_COUNT
if (hiddenThreadsCount > 0 || canShowCursorDrivenMore) {
addLeaf(
data = SessionTreeNode.MoreThreads(project.path),
id = SessionTreeId.MoreThreads(project.path),
)
}
}
}
}
private fun sessionTreeNodeText(node: SessionTreeNode): String? =
when (node) {
is SessionTreeNode.Project -> node.project.name
is SessionTreeNode.Thread -> node.thread.title
is SessionTreeNode.SubAgent -> node.subAgent.name.ifBlank { node.subAgent.id }
is SessionTreeNode.MoreThreads -> CodexSessionsBundle.message("toolwindow.action.more")
is SessionTreeNode.MoreError -> node.message
is SessionTreeNode.Error -> null
is SessionTreeNode.Empty -> node.message
}
internal sealed interface SessionTreeNode {
data class Project(val project: CodexProjectSessions) : SessionTreeNode
data class Thread(val project: CodexProjectSessions, val thread: CodexThread) : SessionTreeNode
data class SubAgent(
val project: CodexProjectSessions,
val thread: CodexThread,
val subAgent: CodexSubAgent,
) : SessionTreeNode
data class MoreThreads(val projectPath: String) : SessionTreeNode
data class MoreError(val projectPath: String, val message: String) : SessionTreeNode
data class Error(val project: CodexProjectSessions, val message: String) : SessionTreeNode
data class Empty(val project: CodexProjectSessions, val message: String) : SessionTreeNode
}
internal sealed interface SessionTreeId {
data class Project(val path: String) : SessionTreeId
data class Thread(val projectPath: String, val threadId: String) : SessionTreeId
data class SubAgent(val projectPath: String, val threadId: String, val subAgentId: String) : SessionTreeId
data class MoreThreads(val projectPath: String) : SessionTreeId
data class MoreError(val projectPath: String) : SessionTreeId
data class Error(val projectPath: String) : SessionTreeId
data class Empty(val projectPath: String) : SessionTreeId
}

View File

@@ -0,0 +1,334 @@
package com.intellij.agent.workbench.sessions
import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import com.intellij.agent.workbench.codex.common.CodexProjectSessions
import com.intellij.agent.workbench.codex.common.CodexSubAgent
import com.intellij.agent.workbench.codex.common.CodexThread
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
import org.jetbrains.jewel.foundation.lazy.SelectableLazyItemScope
import org.jetbrains.jewel.foundation.lazy.tree.Tree
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.theme.LocalContentColor
import org.jetbrains.jewel.ui.component.CircularProgressIndicator
import org.jetbrains.jewel.ui.component.ContextMenuItemOption
import org.jetbrains.jewel.ui.component.Icon
import org.jetbrains.jewel.ui.component.OutlinedButton
import org.jetbrains.jewel.ui.component.Text
import org.jetbrains.jewel.ui.component.search.highlightSpeedSearchMatches
import org.jetbrains.jewel.ui.component.search.highlightTextSearch
import org.jetbrains.jewel.ui.icons.AllIconsKeys
@OptIn(ExperimentalJewelApi::class)
@Composable
internal fun SelectableLazyItemScope.sessionTreeNodeContent(
element: Tree.Element<SessionTreeNode>,
onOpenProject: (String) -> Unit,
onCreateThread: (String) -> Unit,
onShowMoreThreads: (String) -> Unit,
onRefresh: () -> Unit,
nowProvider: () -> Long,
) {
val node = element.data
when (node) {
is SessionTreeNode.Project -> projectNodeRow(
project = node.project,
onOpenProject = onOpenProject,
onCreateThread = onCreateThread,
)
is SessionTreeNode.Thread -> threadNodeRow(
thread = node.thread,
nowProvider = nowProvider,
)
is SessionTreeNode.SubAgent -> subAgentNodeRow(
subAgent = node.subAgent,
)
is SessionTreeNode.MoreThreads -> moreThreadsNodeRow()
is SessionTreeNode.MoreError -> errorNodeRow(
message = node.message,
onRetry = { onShowMoreThreads(node.projectPath) },
)
is SessionTreeNode.Error -> errorNodeRow(
message = node.message,
onRetry = onRefresh,
)
is SessionTreeNode.Empty -> emptyNodeRow(
message = node.message,
)
}
}
private data class TreeRowChrome(
val interactionSource: MutableInteractionSource,
val isHovered: Boolean,
val background: Color,
val shape: Shape,
val spacing: Dp,
val indicatorPadding: Dp,
)
@Composable
private fun rememberTreeRowChrome(
isSelected: Boolean,
isActive: Boolean,
baseTint: Color = Color.Unspecified,
): TreeRowChrome {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val background = treeRowBackground(
isHovered = isHovered,
isSelected = isSelected,
isActive = isActive,
baseTint = baseTint,
)
val shape = treeRowShape()
val spacing = treeRowSpacing()
val indicatorPadding = spacing * 0.4f
return TreeRowChrome(
interactionSource = interactionSource,
isHovered = isHovered,
background = background,
shape = shape,
spacing = spacing,
indicatorPadding = indicatorPadding,
)
}
@OptIn(ExperimentalJewelApi::class)
@Composable
private fun SelectableLazyItemScope.projectNodeRow(
project: CodexProjectSessions,
onOpenProject: (String) -> Unit,
onCreateThread: (String) -> Unit,
) {
val chrome = rememberTreeRowChrome(
isSelected = isSelected,
isActive = isActive,
baseTint = projectRowTint(),
)
val openLabel = CodexSessionsBundle.message("toolwindow.action.open")
val newThreadLabel = CodexSessionsBundle.message("toolwindow.action.new.thread")
ContextMenuArea(
items = {
if (!project.isOpen) {
listOf(
ContextMenuItemOption(
label = openLabel,
action = { onOpenProject(project.path) },
),
)
} else {
emptyList()
}
},
enabled = !project.isOpen,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(chrome.background, chrome.shape)
.hoverable(chrome.interactionSource),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(chrome.spacing)
) {
var titleLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = project.name.highlightTextSearch(),
style = CodexSessionsTextStyles.projectTitle(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLayoutResult = it },
modifier = Modifier
.weight(1f)
.highlightSpeedSearchMatches(titleLayoutResult),
)
Box(
modifier = Modifier.size(projectActionSlotSize()),
contentAlignment = Alignment.Center,
) {
if (project.isLoading) {
CircularProgressIndicator(Modifier.size(loadingIndicatorSize()))
}
else if (chrome.isHovered) {
Icon(
key = AllIconsKeys.General.Add,
contentDescription = newThreadLabel,
modifier = Modifier
.size(projectActionIconSize())
.pointerHoverIcon(PointerIcon.Hand, overrideDescendants = true)
.clickable(onClick = { onCreateThread(project.path) }),
)
}
}
}
}
}
@OptIn(ExperimentalJewelApi::class)
@Composable
private fun SelectableLazyItemScope.threadNodeRow(
thread: CodexThread,
nowProvider: () -> Long,
) {
val timestamp = thread.updatedAt.takeIf { it > 0 }
val timeLabel = timestamp?.let { formatRelativeTimeShort(it, nowProvider()) }
val chrome = rememberTreeRowChrome(isSelected = isSelected, isActive = isActive)
val titleColor = if (isSelected || isActive) Color.Unspecified else {
JewelTheme.globalColors.text.normal.copy(alpha = 0.84f)
}
val timeColor = LocalContentColor.current
.takeOrElse { JewelTheme.globalColors.text.disabled }
.copy(alpha = 0.55f)
Row(
modifier = Modifier
.fillMaxWidth()
.background(chrome.background, chrome.shape)
.hoverable(chrome.interactionSource),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(chrome.spacing)
) {
Box(
modifier = Modifier
.padding(end = chrome.indicatorPadding)
.size(threadIndicatorSize())
.background(threadIndicatorColor(thread), CircleShape)
)
var titleLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = thread.title.highlightTextSearch(),
style = CodexSessionsTextStyles.threadTitle(),
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLayoutResult = it },
modifier = Modifier
.weight(1f)
.highlightSpeedSearchMatches(titleLayoutResult),
)
if (timeLabel != null) {
Text(
text = timeLabel,
color = timeColor,
style = CodexSessionsTextStyles.threadTime(),
)
}
}
}
@OptIn(ExperimentalJewelApi::class)
@Composable
private fun SelectableLazyItemScope.subAgentNodeRow(
subAgent: CodexSubAgent,
) {
val chrome = rememberTreeRowChrome(isSelected = isSelected, isActive = isActive)
val displayName = subAgent.name.ifBlank { subAgent.id }
val titleColor = if (isSelected || isActive) Color.Unspecified else JewelTheme.globalColors.text.disabled
Row(
modifier = Modifier
.fillMaxWidth()
.background(chrome.background, chrome.shape)
.hoverable(chrome.interactionSource),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(chrome.spacing)
) {
Box(
modifier = Modifier
.padding(end = chrome.indicatorPadding)
.size(subAgentIndicatorSize())
.background(subAgentIndicatorColor(), CircleShape)
)
var titleLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = displayName.highlightTextSearch(),
style = CodexSessionsTextStyles.subAgentTitle(),
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLayoutResult = it },
modifier = Modifier
.weight(1f)
.highlightSpeedSearchMatches(titleLayoutResult),
)
}
}
@OptIn(ExperimentalJewelApi::class)
@Composable
private fun SelectableLazyItemScope.moreThreadsNodeRow() {
val chrome = rememberTreeRowChrome(isSelected = isSelected, isActive = isActive)
val label = CodexSessionsBundle.message("toolwindow.action.more")
Row(
modifier = Modifier
.fillMaxWidth()
.background(chrome.background, chrome.shape)
.hoverable(chrome.interactionSource),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(chrome.spacing),
) {
Text(
text = label.highlightTextSearch(),
color = JewelTheme.globalColors.text.info,
style = CodexSessionsTextStyles.subAgentTitle(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun errorNodeRow(message: String, onRetry: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth()) {
val rowSpacing = treeRowSpacing()
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(rowSpacing)
) {
Text(
text = message,
color = JewelTheme.globalColors.text.warning,
style = CodexSessionsTextStyles.error(),
)
OutlinedButton(onClick = onRetry) {
Text(CodexSessionsBundle.message("toolwindow.error.retry"))
}
}
}
}
@Composable
private fun emptyNodeRow(message: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = message,
color = JewelTheme.globalColors.text.disabled,
style = CodexSessionsTextStyles.emptyState(),
)
}
}

View File

@@ -0,0 +1,67 @@
package com.intellij.agent.workbench.sessions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import org.jetbrains.jewel.foundation.lazy.tree.TreeState
import org.jetbrains.jewel.foundation.lazy.tree.rememberTreeState
/**
* Tracks tree open nodes and auto-open behavior for on-demand project loading.
*
* Spec: `community/plugins/codex/spec/codex-sessions.spec.md`
*/
@Stable
internal class SessionTreeStateHolder(
val treeState: TreeState,
) {
private var openedProjects by mutableStateOf<Set<SessionTreeId.Project>>(emptySet())
fun updateOpenedProjects(
openProjects: Set<SessionTreeId.Project>,
onProjectExpanded: (String) -> Unit,
onProjectCollapsed: (String) -> Unit,
) {
val newlyOpened = openProjects - openedProjects
val newlyCollapsed = openedProjects - openProjects
if (newlyOpened.isNotEmpty()) {
newlyOpened.forEach { onProjectExpanded(it.path) }
}
if (newlyCollapsed.isNotEmpty()) {
newlyCollapsed.forEach { onProjectCollapsed(it.path) }
}
if (openProjects != openedProjects) {
openedProjects = openProjects
}
}
fun applyDefaultOpenProjects(defaultOpenProjects: List<SessionTreeId.Project>) {
val currentlyOpenProjects = treeState.openNodes.filterIsInstance<SessionTreeId.Project>().toSet()
val projectsToOpen = defaultOpenProjects.filterNot { it in currentlyOpenProjects }
if (projectsToOpen.isNotEmpty()) {
treeState.openNodes(projectsToOpen)
}
}
}
@Composable
internal fun rememberSessionTreeStateHolder(
onProjectExpanded: (String) -> Unit,
onProjectCollapsed: (String) -> Unit,
): SessionTreeStateHolder {
val treeState = rememberTreeState()
val stateHolder = remember(treeState) { SessionTreeStateHolder(treeState) }
LaunchedEffect(treeState, stateHolder, onProjectExpanded, onProjectCollapsed) {
snapshotFlow { treeState.openNodes }
.collect { nodes ->
val openProjects = nodes.filterIsInstance<SessionTreeId.Project>().toSet()
stateHolder.updateOpenedProjects(openProjects, onProjectExpanded, onProjectCollapsed)
}
}
return stateHolder
}

View File

@@ -0,0 +1,144 @@
package com.intellij.agent.workbench.sessions
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.intellij.agent.workbench.codex.common.CodexThread
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.theme.simpleListItemStyle
import org.jetbrains.jewel.ui.theme.treeStyle
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToLong
@Composable
internal fun threadIndicatorColor(thread: CodexThread): Color {
// TODO: map thread status to indicator color once status is available.
return if (thread.archived) {
JewelTheme.globalColors.text.disabled
} else {
JewelTheme.globalColors.text.info.copy(alpha = 0.8f)
}
}
@Composable
internal fun subAgentIndicatorColor(): Color {
// TODO: map sub-agent status to indicator color once status is available.
return JewelTheme.globalColors.text.disabled
}
@Composable
internal fun treeRowBackground(
isHovered: Boolean,
isSelected: Boolean,
isActive: Boolean,
baseTint: Color = Color.Unspecified,
): Color {
if (isSelected || isActive) return Color.Transparent
val hoverBase = rowHoverBase()
if (isHovered && hoverBase.isSpecified) {
return hoverBase.copy(alpha = 0.12f)
}
return if (baseTint.isSpecified) baseTint.copy(alpha = 0.06f) else Color.Transparent
}
@Composable
internal fun treeRowShape(): RoundedCornerShape {
val cornerSize = JewelTheme.treeStyle.metrics.simpleListItemMetrics.selectionBackgroundCornerSize
return RoundedCornerShape(cornerSize)
}
@Composable
internal fun treeRowSpacing(): Dp {
return JewelTheme.treeStyle.metrics.simpleListItemMetrics.iconTextGap
}
@Composable
internal fun projectRowTint(): Color {
return projectRowTintBase()
}
@Composable
internal fun threadIndicatorSize(): Dp {
return indicatorSize(scale = 0.18f, min = 4.dp, max = 8.dp)
}
@Composable
internal fun subAgentIndicatorSize(): Dp {
return indicatorSize(scale = 0.14f, min = 3.dp, max = 6.dp)
}
@Composable
internal fun loadingIndicatorSize(): Dp {
return indicatorSize(scale = 0.28f, min = 10.dp, max = 14.dp)
}
@Composable
internal fun projectActionIconSize(): Dp {
return indicatorSize(scale = 0.28f, min = 14.dp, max = 18.dp)
}
@Composable
internal fun projectActionSlotSize(): Dp {
val iconSize = projectActionIconSize()
val loadingSize = loadingIndicatorSize()
return if (iconSize > loadingSize) iconSize else loadingSize
}
@Composable
private fun indicatorSize(scale: Float, min: Dp, max: Dp): Dp {
val baseSize = JewelTheme.treeStyle.metrics.elementMinHeight * scale
return baseSize.coerceIn(min, max)
}
@Composable
private fun projectRowTintBase(): Color {
val treeColors = JewelTheme.treeStyle.colors
val listColors = JewelTheme.simpleListItemStyle.colors
return treeColors.backgroundSelected
.takeOrElse { treeColors.backgroundSelectedActive }
.takeOrElse { listColors.backgroundSelected }
.takeOrElse { listColors.backgroundSelectedActive }
}
@Composable
private fun rowHoverBase(): Color {
val treeColors = JewelTheme.treeStyle.colors
val listColors = JewelTheme.simpleListItemStyle.colors
return listColors.backgroundActive
.takeOrElse { treeColors.backgroundSelectedActive }
.takeOrElse { treeColors.backgroundSelected }
}
internal fun formatRelativeTimeShort(timestamp: Long, now: Long): String {
val absSeconds = abs(((timestamp - now) / 1000.0).roundToLong())
if (absSeconds < 60) {
return CodexSessionsBundle.message("toolwindow.time.now")
}
if (absSeconds < 60 * 60) {
val value = max(1, (absSeconds / 60.0).roundToLong())
return "${value}m"
}
if (absSeconds < 60 * 60 * 24) {
val value = max(1, (absSeconds / (60.0 * 60.0)).roundToLong())
return "${value}h"
}
if (absSeconds < 60 * 60 * 24 * 7) {
val value = max(1, (absSeconds / (60.0 * 60.0 * 24.0)).roundToLong())
return "${value}d"
}
if (absSeconds < 60 * 60 * 24 * 30) {
val value = max(1, (absSeconds / (60.0 * 60.0 * 24.0 * 7.0)).roundToLong())
return "${value}w"
}
if (absSeconds < 60 * 60 * 24 * 365) {
val value = max(1, (absSeconds / (60.0 * 60.0 * 24.0 * 30.0)).roundToLong())
return "${value}mo"
}
val value = max(1, (absSeconds / (60.0 * 60.0 * 24.0 * 365.0)).roundToLong())
return "${value}y"
}

View File

@@ -0,0 +1,390 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.agent.workbench.codex.common.CodexAppServerException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.nio.file.Files
import java.nio.file.Path
class CodexAppServerClientTest {
companion object {
@JvmStatic
fun backends(): List<CodexBackend> {
return listOf(
createMockBackendDefinition(),
createRealBackendDefinition(),
)
}
}
@TempDir
lateinit var tempDir: Path
@ParameterizedTest(name = "{0}")
@MethodSource("backends")
fun listThreadsUsesCodexAppServerBackends(backend: CodexBackend): Unit = runBlocking(Dispatchers.IO) {
val configPath = tempDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(id = "thread-1", title = "First session", updatedAt = 1_700_000_000_000L, archived = false),
ThreadSpec(id = "thread-2", title = "Second session", updatedAt = 1_700_000_010_000L, archived = false),
ThreadSpec(id = "thread-3", title = "Archived session", updatedAt = 1_699_999_000_000L, archived = true),
)
)
backend.run(scope = this, tempDir = tempDir, configPath = configPath)
}
@Test
fun listThreadsUsesWorkingDirectory(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-a")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(
id = "thread-1",
title = "Thread One",
cwd = workingDir.toString(),
updatedAt = 1_700_000_000_000L,
archived = false,
),
)
)
val backendDir = tempDir.resolve("backend-a")
Files.createDirectories(backendDir)
val markerName = ".codex-test-cwd"
val client = createMockClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = workingDir,
environmentOverrides = mapOf("CODEX_TEST_CWD_MARKER" to markerName),
)
try {
client.listThreads(archived = false)
}
finally {
client.shutdown()
}
val markerPath = workingDir.resolve(markerName)
assertThat(markerPath).exists()
val recorded = Files.readString(markerPath).trim()
assertThat(Path.of(recorded).toRealPath()).isEqualTo(workingDir.toRealPath())
}
@Test
fun listThreadsUsesSeparateAppServersPerProject(): Unit = runBlocking(Dispatchers.IO) {
val projectA = tempDir.resolve("project-alpha")
val projectB = tempDir.resolve("project-beta")
Files.createDirectories(projectA)
Files.createDirectories(projectB)
val configA = projectA.resolve("codex-config.json")
val configB = projectB.resolve("codex-config.json")
writeConfig(
path = configA,
threads = listOf(
ThreadSpec(
id = "alpha-1",
title = "Alpha",
cwd = projectA.toString(),
updatedAt = 1_700_000_000_000L,
archived = false,
),
),
)
writeConfig(
path = configB,
threads = listOf(
ThreadSpec(
id = "beta-1",
title = "Beta",
cwd = projectB.toString(),
updatedAt = 1_700_000_100_000L,
archived = false,
),
),
)
val backendA = tempDir.resolve("backend-alpha")
val backendB = tempDir.resolve("backend-beta")
Files.createDirectories(backendA)
Files.createDirectories(backendB)
val clientA = createMockClient(
scope = this,
tempDir = backendA,
configPath = configA,
workingDirectory = projectA,
)
val clientB = createMockClient(
scope = this,
tempDir = backendB,
configPath = configB,
workingDirectory = projectB,
)
try {
val threadsA = clientA.listThreads(archived = false)
val threadsB = clientB.listThreads(archived = false)
assertThat(threadsA.map { it.id }).containsExactly("alpha-1")
assertThat(threadsB.map { it.id }).containsExactly("beta-1")
}
finally {
clientA.shutdown()
clientB.shutdown()
}
}
@Test
fun listThreadsPageSupportsCursorAndLimit(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-page")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(id = "thread-1", title = "Thread 1", cwd = workingDir.toString(), updatedAt = 1_700_000_005_000L, archived = false),
ThreadSpec(id = "thread-2", title = "Thread 2", cwd = workingDir.toString(), updatedAt = 1_700_000_004_000L, archived = false),
ThreadSpec(id = "thread-3", title = "Thread 3", cwd = workingDir.toString(), updatedAt = 1_700_000_003_000L, archived = false),
ThreadSpec(id = "thread-4", title = "Thread 4", cwd = workingDir.toString(), updatedAt = 1_700_000_002_000L, archived = false),
ThreadSpec(id = "thread-5", title = "Thread 5", cwd = workingDir.toString(), updatedAt = 1_700_000_001_000L, archived = false),
)
)
val backendDir = tempDir.resolve("backend-page")
Files.createDirectories(backendDir)
val client = createMockClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = workingDir,
)
try {
val first = client.listThreadsPage(archived = false, cursor = null, limit = 2)
assertThat(first.threads.map { it.id }).containsExactly("thread-1", "thread-2")
assertThat(first.nextCursor).isEqualTo("2")
val second = client.listThreadsPage(archived = false, cursor = first.nextCursor, limit = 2)
assertThat(second.threads.map { it.id }).containsExactly("thread-3", "thread-4")
assertThat(second.nextCursor).isEqualTo("4")
}
finally {
client.shutdown()
}
}
@Test
fun listThreadsParsesPreviewAndTimestampVariants(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-preview")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
val longPreview = "x".repeat(160)
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(
id = "thread-preview",
preview = longPreview,
cwd = workingDir.toString(),
updatedAt = 1_700_000_000L,
updatedAtField = "updated_at",
archived = false,
),
ThreadSpec(
id = "thread-name",
name = "Named thread",
cwd = workingDir.toString(),
createdAt = 1_700_000_500_000L,
createdAtField = "createdAt",
archived = false,
),
),
)
val backendDir = tempDir.resolve("backend-preview")
Files.createDirectories(backendDir)
val client = createMockClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = workingDir,
)
try {
val threads = client.listThreads(archived = false)
val threadsById = threads.associateBy { it.id }
val previewThread = threadsById.getValue("thread-preview")
assertThat(previewThread.updatedAt).isEqualTo(1_700_000_000_000L)
assertThat(previewThread.title).endsWith("...")
assertThat(previewThread.title.length).isLessThan(longPreview.length)
val namedThread = threadsById.getValue("thread-name")
assertThat(namedThread.title).isEqualTo("Named thread")
assertThat(namedThread.updatedAt).isEqualTo(1_700_000_500_000L)
}
finally {
client.shutdown()
}
}
@Test
fun listThreadsFailsOnServerError(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-error")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(
id = "thread-err",
title = "Thread",
cwd = workingDir.toString(),
updatedAt = 1_700_000_000_000L,
archived = false,
),
),
)
val backendDir = tempDir.resolve("backend-error")
Files.createDirectories(backendDir)
val client = createMockClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = workingDir,
environmentOverrides = mapOf(
"CODEX_TEST_ERROR_METHOD" to "thread/list",
"CODEX_TEST_ERROR_MESSAGE" to "boom",
),
)
try {
try {
client.listThreads(archived = false)
fail("Expected CodexAppServerException")
}
catch (e: CodexAppServerException) {
assertThat(e.message).contains("boom")
}
}
finally {
client.shutdown()
}
}
@Test
fun listThreadsFiltersByCwd(): Unit = runBlocking(Dispatchers.IO) {
val projectA = tempDir.resolve("project-cwd-a")
val projectB = tempDir.resolve("project-cwd-b")
Files.createDirectories(projectA)
Files.createDirectories(projectB)
val configPath = tempDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(
id = "thread-a",
title = "Alpha",
cwd = projectA.toString(),
updatedAt = 1_700_000_000_000L,
archived = false,
),
ThreadSpec(
id = "thread-b",
title = "Beta",
cwd = projectB.toString(),
updatedAt = 1_700_000_100_000L,
archived = false,
),
),
)
val backendDir = tempDir.resolve("backend-cwd")
Files.createDirectories(backendDir)
val client = createMockClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = projectA,
)
try {
val threads = client.listThreads(archived = false)
assertThat(threads.map { it.id }).containsExactly("thread-a")
}
finally {
client.shutdown()
}
}
@Test
fun createThreadStartsNewThreadAndAddsItToList(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-start")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(
id = "thread-old",
title = "Old Thread",
cwd = workingDir.toString(),
updatedAt = 1_700_000_000_000L,
archived = false,
),
),
)
val backendDir = tempDir.resolve("backend-start")
Files.createDirectories(backendDir)
val client = createMockClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = workingDir,
)
try {
val created = client.createThread()
assertThat(created.id).startsWith("thread-start-")
assertThat(created.archived).isFalse()
assertThat(created.title).isNotBlank()
val active = client.listThreads(archived = false)
assertThat(active.first().id).isEqualTo(created.id)
assertThat(active.map { it.id }).contains("thread-old")
}
finally {
client.shutdown()
}
}
@Test
fun createThreadFailsOnServerError(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-start-error")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(path = configPath, threads = emptyList())
val backendDir = tempDir.resolve("backend-start-error")
Files.createDirectories(backendDir)
val client = createMockClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = workingDir,
environmentOverrides = mapOf(
"CODEX_TEST_ERROR_METHOD" to "thread/start",
"CODEX_TEST_ERROR_MESSAGE" to "boom",
),
)
try {
try {
client.createThread()
fail("Expected CodexAppServerException")
}
catch (e: CodexAppServerException) {
assertThat(e.message).contains("boom")
}
}
finally {
client.shutdown()
}
}
}

View File

@@ -0,0 +1,303 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.fasterxml.jackson.core.JsonFactory
import com.intellij.agent.workbench.codex.common.CodexAppServerClient
import com.intellij.agent.workbench.codex.common.CodexThread
import com.intellij.execution.CommandLineWrapperUtil
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
import com.intellij.openapi.util.io.NioFiles
import com.intellij.util.system.OS
import kotlinx.coroutines.CoroutineScope
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Assumptions.assumeTrue
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.writeText
interface CodexBackend {
val name: String
suspend fun createClient(
scope: CoroutineScope,
tempDir: Path,
configPath: Path,
workingDirectory: Path? = null,
): CodexAppServerClient
suspend fun run(scope: CoroutineScope, tempDir: Path, configPath: Path)
}
internal fun createMockBackendDefinition(): CodexBackend {
return object : CodexBackend {
override val name: String
get() = "mock"
override suspend fun createClient(
scope: CoroutineScope,
tempDir: Path,
configPath: Path,
workingDirectory: Path?,
): CodexAppServerClient {
return createMockClient(
scope = scope,
tempDir = tempDir,
configPath = configPath,
workingDirectory = workingDirectory,
)
}
override suspend fun run(scope: CoroutineScope, tempDir: Path, configPath: Path) {
val client = createClient(
scope = scope,
tempDir = tempDir,
configPath = configPath,
)
try {
assertThreads(
backendName = name,
client = client,
expectedActiveIds = listOf("thread-2", "thread-1"),
expectedArchivedIds = listOf("thread-3"),
)
}
finally {
client.shutdown()
}
}
override fun toString(): String = name
}
}
internal fun createRealBackendDefinition(): CodexBackend {
return object : CodexBackend {
override val name: String
get() = "real"
override suspend fun createClient(
scope: CoroutineScope,
tempDir: Path,
configPath: Path,
workingDirectory: Path?,
): CodexAppServerClient {
val codexBinary = resolveCodexBinary()
assumeTrue(codexBinary != null, "Codex CLI not found. Set CODEX_BIN or ensure codex is on PATH.")
val codexHome = createCodexHome(tempDir)
return CodexAppServerClient(
scope,
executablePathProvider = { codexBinary!! },
environmentOverrides = mapOf("CODEX_HOME" to codexHome.toString()),
workingDirectory = workingDirectory,
)
}
override suspend fun run(scope: CoroutineScope, tempDir: Path, configPath: Path) {
val client = createClient(
scope = scope,
tempDir = tempDir,
configPath = configPath,
)
try {
assertThreads(
backendName = name,
client = client,
expectedActiveIds = null,
expectedArchivedIds = null,
)
}
finally {
client.shutdown()
}
}
override fun toString(): String = name
}
}
internal fun writeConfig(path: Path, threads: List<ThreadSpec>) {
val jsonFactory = JsonFactory()
Files.newBufferedWriter(path, StandardCharsets.UTF_8).use { writer ->
jsonFactory.createGenerator(writer).use { generator ->
generator.writeStartObject()
generator.writeFieldName("threads")
generator.writeStartArray()
for (thread in threads) {
generator.writeStartObject()
generator.writeStringField("id", thread.id)
thread.title?.let { generator.writeStringField("title", it) }
thread.preview?.let { generator.writeStringField("preview", it) }
thread.name?.let { generator.writeStringField("name", it) }
thread.summary?.let { generator.writeStringField("summary", it) }
thread.cwd?.let { generator.writeStringField("cwd", it) }
thread.updatedAt?.let { updatedAt ->
val field = thread.updatedAtField.takeIf { it.isNotBlank() } ?: "updated_at"
generator.writeNumberField(field, updatedAt)
}
thread.createdAt?.let { createdAt ->
val field = thread.createdAtField.takeIf { it.isNotBlank() } ?: "created_at"
generator.writeNumberField(field, createdAt)
}
generator.writeBooleanField("archived", thread.archived)
generator.writeEndObject()
}
generator.writeEndArray()
generator.writeEndObject()
}
}
}
internal data class ThreadSpec(
val id: String,
val title: String? = null,
val preview: String? = null,
val name: String? = null,
val summary: String? = null,
val cwd: String? = null,
val updatedAt: Long? = null,
val createdAt: Long? = null,
val updatedAtField: String = "updated_at",
val createdAtField: String = "created_at",
val archived: Boolean = false,
)
internal fun createMockClient(
scope: CoroutineScope,
tempDir: Path,
configPath: Path,
workingDirectory: Path? = null,
environmentOverrides: Map<String, String> = emptyMap(),
): CodexAppServerClient {
val codexPath = createCodexShim(tempDir, configPath)
return CodexAppServerClient(
scope,
executablePathProvider = { codexPath.toString() },
environmentOverrides = environmentOverrides,
workingDirectory = workingDirectory,
)
}
private fun createCodexShim(tempDir: Path, configPath: Path): Path {
val javaHome = System.getProperty("java.home")
val javaBin = Path.of(javaHome, "bin", if (OS.CURRENT == OS.Windows) "java.exe" else "java")
val classpath = resolveTestClasspath()
val argsFile = writeAppServerArgsFile(tempDir, classpath, configPath)
return if (OS.CURRENT == OS.Windows) {
val script = tempDir.resolve("codex.cmd")
script.writeText(
"""
@echo off
"${javaBin}" "@${argsFile}"
""".trimIndent()
)
NioFiles.setExecutable(script)
script
}
else {
val script = tempDir.resolve("codex")
val quotedJava = quoteForShell(javaBin.toString())
val quotedArgsFile = quoteForShell("@${argsFile}")
script.writeText(
"""
#!/bin/sh
exec $quotedJava $quotedArgsFile
""".trimIndent()
)
NioFiles.setExecutable(script)
script
}
}
private fun resolveTestClasspath(): String {
val classpathFile = System.getProperty("classpath.file")
if (!classpathFile.isNullOrBlank()) {
val entries = Files.readAllLines(Path.of(classpathFile), StandardCharsets.UTF_8)
.map(String::trim)
.filter(String::isNotEmpty)
if (entries.isNotEmpty()) {
return entries.joinToString(File.pathSeparator)
}
}
return System.getProperty("java.class.path")
}
private fun writeAppServerArgsFile(tempDir: Path, classpath: String, configPath: Path): Path {
val argsFile = tempDir.resolve("codex-app-server.args")
val args = listOf("-cp", classpath, TEST_APP_SERVER_MAIN_CLASS, configPath.toString())
Files.newBufferedWriter(argsFile, StandardCharsets.UTF_8).use { writer ->
for (arg in args) {
writer.write(CommandLineWrapperUtil.quoteArg(arg))
writer.newLine()
}
}
return argsFile
}
private fun quoteForShell(value: String): String {
return "'" + value.replace("'", "'\"'\"'") + "'"
}
private suspend fun assertThreads(
backendName: String,
client: CodexAppServerClient,
expectedActiveIds: List<String>?,
expectedArchivedIds: List<String>?,
) {
val active = client.listThreads(archived = false)
val archived = client.listThreads(archived = true)
val firstPage = client.listThreadsPage(archived = false, cursor = null, limit = 2)
assertThat(active).describedAs("$backendName active threads").allMatch { !it.archived }
assertThat(archived).describedAs("$backendName archived threads").allMatch { it.archived }
assertThat(firstPage.threads).describedAs("$backendName paged active threads").allMatch { !it.archived }
assertThat(firstPage.threads.size).describedAs("$backendName paged active page size").isLessThanOrEqualTo(2)
val comparator = Comparator.comparingLong<CodexThread> { it.updatedAt }.reversed()
assertThat(active).describedAs("$backendName active sort").isSortedAccordingTo(comparator)
assertThat(archived).describedAs("$backendName archived sort").isSortedAccordingTo(comparator)
if (expectedActiveIds != null) {
assertThat(active.map { it.id })
.describedAs("$backendName active ids")
.containsExactlyElementsOf(expectedActiveIds)
assertThat(firstPage.threads.map { it.id })
.describedAs("$backendName paged active ids")
.containsExactlyElementsOf(expectedActiveIds.take(2))
}
if (expectedArchivedIds != null) {
assertThat(archived.map { it.id })
.describedAs("$backendName archived ids")
.containsExactlyElementsOf(expectedArchivedIds)
}
}
private fun resolveCodexBinary(): String? {
val configured = System.getenv("CODEX_BIN")?.takeIf { it.isNotBlank() }
return configured ?: PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("codex")?.absolutePath
}
private fun createCodexHome(tempDir: Path): Path {
val codexHome = tempDir.resolve("codex-home")
Files.createDirectories(codexHome)
writeCodexConfig(codexHome.resolve("config.toml"))
return codexHome
}
private fun writeCodexConfig(configPath: Path) {
val lines = mutableListOf<String>()
val model = System.getenv("CODEX_MODEL")?.takeIf { it.isNotBlank() } ?: DEFAULT_TEST_MODEL
val reasoningEffort = System.getenv("CODEX_REASONING_EFFORT")?.takeIf { it.isNotBlank() } ?: DEFAULT_TEST_REASONING_EFFORT
lines.add("model = \"$model\"")
lines.add("model_reasoning_effort = \"$reasoningEffort\"")
lines.add("approval_policy = \"never\"")
lines.add("cli_auth_credentials_store = \"file\"")
lines.add("")
configPath.writeText(lines.joinToString("\n"))
}
private const val DEFAULT_TEST_MODEL = "gpt-4o-mini"
private const val DEFAULT_TEST_REASONING_EFFORT = "low"
private const val TEST_APP_SERVER_MAIN_CLASS = "com.intellij.agent.workbench.sessions.CodexTestAppServer"

View File

@@ -0,0 +1,88 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
class CodexProjectSessionServiceTest {
@TempDir
lateinit var tempDir: Path
@Test
fun shutdownHookRunsOnCancellation() = runBlocking {
val parentJob = coroutineContext.job
@Suppress("RAW_SCOPE_CREATION")
val scope = CoroutineScope(coroutineContext + Job(parentJob))
val shutdown = CompletableDeferred<Unit>()
registerShutdownOnCancellation(scope) {
shutdown.complete(Unit)
}
scope.cancel()
withTimeout(1_000) {
shutdown.await()
}
}
@Test
fun resolvesDirectoryFromIdeaMiscXml() {
val projectRoot = tempDir.resolve("project-a")
val ideaDir = projectRoot.resolve(".idea")
Files.createDirectories(ideaDir)
val miscXml = ideaDir.resolve("misc.xml")
Files.writeString(miscXml, "<project />")
val resolved = resolveProjectDirectory(
recentProjectPath = miscXml,
projectFilePath = null,
basePath = null,
guessedProjectDir = null,
)
assertThat(resolved).isEqualTo(projectRoot)
}
@Test
fun resolvesDirectoryFromIprFilePath() {
val projectRoot = tempDir.resolve("project-b")
Files.createDirectories(projectRoot)
val iprFile = projectRoot.resolve("project.ipr")
Files.writeString(iprFile, "<project></project>")
val resolved = resolveProjectDirectory(
recentProjectPath = null,
projectFilePath = iprFile.toString(),
basePath = null,
guessedProjectDir = null,
)
assertThat(resolved).isEqualTo(projectRoot)
}
@Test
fun fallsBackToGuessedDirectoryWhenPathsMissing() {
val projectRoot = tempDir.resolve("project-c")
Files.createDirectories(projectRoot)
val resolved = resolveProjectDirectory(
recentProjectPath = null,
projectFilePath = null,
basePath = null,
guessedProjectDir = projectRoot,
)
assertThat(resolved).isEqualTo(projectRoot)
}
}

View File

@@ -0,0 +1,85 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.TestActionEvent
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.runInEdtAndWait
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
@TestApplication
class CodexSessionsGearActionsTest {
@Test
fun gearActionsContainOpenFileToggleAndRefresh() {
val actionManager = ActionManager.getInstance()
val group = actionManager.getAction("AgentWorkbenchSessions.ToolWindow.GearActions")
assertThat(group).isNotNull.isInstanceOf(ActionGroup::class.java)
val children = (group as ActionGroup).getChildren(TestActionEvent.createTestEvent())
val actionIds = children
.filterNot { it is Separator }
.mapNotNull { actionManager.getId(it) }
assertThat(actionIds).containsExactly(
"OpenFile",
"AgentWorkbenchSessions.ToggleDedicatedFrame",
"AgentWorkbenchSessions.Refresh",
)
}
@Test
fun toggleActionUpdatesAdvancedSetting() {
val actionManager = ActionManager.getInstance()
val action = actionManager.getAction("AgentWorkbenchSessions.ToggleDedicatedFrame")
assertThat(action).isNotNull
val toggleAction = action as? CodexSessionsDedicatedFrameToggleAction
?: error("Toggle action is missing")
val initialValue = CodexChatOpenModeSettings.openInDedicatedFrame()
try {
runInEdtAndWait {
toggleAction.setSelected(TestActionEvent.createTestEvent(toggleAction), !initialValue)
}
assertThat(CodexChatOpenModeSettings.openInDedicatedFrame()).isEqualTo(!initialValue)
}
finally {
CodexChatOpenModeSettings.setOpenInDedicatedFrame(initialValue)
}
}
@Test
fun refreshActionTriggersSessionsRefresh() {
val actionManager = ActionManager.getInstance()
val refreshAction = actionManager.getAction("AgentWorkbenchSessions.Refresh")
assertThat(refreshAction).isNotNull
val notNullRefreshAction = refreshAction ?: error("Refresh action is missing")
val service = ApplicationManager.getApplication().getService(CodexSessionsService::class.java)
val initialTimestamp = service.state.value.lastUpdatedAt
runInEdtAndWait {
notNullRefreshAction.actionPerformed(TestActionEvent.createTestEvent(notNullRefreshAction))
}
val timeoutAt = System.currentTimeMillis() + 5_000
var refreshed = false
while (System.currentTimeMillis() < timeoutAt) {
val updatedAt = service.state.value.lastUpdatedAt
if (updatedAt != null && updatedAt != initialTimestamp) {
refreshed = true
break
}
Thread.sleep(20)
}
assertThat(refreshed)
.withFailMessage("Refresh action didn't trigger a sessions state update in time.")
.isTrue()
}
}

View File

@@ -0,0 +1,62 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import org.junit.Assert.assertEquals
import org.junit.Test
class CodexSessionsOpenModeRoutingTest {
@Test
fun dedicatedModeRoutesToDedicatedFrameWhenSourceProjectIsOpen() {
val route = resolveChatOpenRoute(
openInDedicatedFrame = true,
hasOpenSourceProject = true,
)
assertEquals(CodexChatOpenRoute.DedicatedFrame, route)
}
@Test
fun dedicatedModeRoutesToDedicatedFrameWhenSourceProjectIsClosed() {
val route = resolveChatOpenRoute(
openInDedicatedFrame = true,
hasOpenSourceProject = false,
)
assertEquals(CodexChatOpenRoute.DedicatedFrame, route)
}
@Test
fun currentProjectModeRoutesToOpenProjectWhenAlreadyOpen() {
val route = resolveChatOpenRoute(
openInDedicatedFrame = false,
hasOpenSourceProject = true,
)
assertEquals(CodexChatOpenRoute.CurrentProject, route)
}
@Test
fun currentProjectModeRoutesToOpenSourceProjectWhenClosedAndPathValid() {
val route = resolveChatOpenRoute(
openInDedicatedFrame = false,
hasOpenSourceProject = false,
)
assertEquals(CodexChatOpenRoute.OpenSourceProject, route)
}
@Test
fun threadAndSubAgentUseTheSameRouteDecision() {
val threadRoute = resolveChatOpenRoute(
openInDedicatedFrame = false,
hasOpenSourceProject = false,
)
val subAgentRoute = resolveChatOpenRoute(
openInDedicatedFrame = false,
hasOpenSourceProject = false,
)
assertEquals(CodexChatOpenRoute.OpenSourceProject, threadRoute)
assertEquals(threadRoute, subAgentRoute)
}
}

View File

@@ -0,0 +1,104 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.agent.workbench.codex.common.CodexThread
import com.intellij.agent.workbench.codex.common.CodexThreadPage
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CodexSessionsPagingLogicTest {
@Test
fun seedInitialVisibleThreadsFetchesUntilMinimumIsReached() = runBlocking {
var pageRequests = 0
val result = seedInitialVisibleThreads(
initialPage = CodexThreadPage(
threads = listOf(
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = 1_000L, archived = false),
CodexThread(id = "thread-2", title = "Thread 2", updatedAt = 2_000L, archived = false),
),
nextCursor = "cursor-1",
),
minimumVisibleThreads = 3,
loadNextPage = { cursor ->
pageRequests += 1
assertEquals("cursor-1", cursor)
CodexThreadPage(
threads = listOf(
CodexThread(id = "thread-3", title = "Thread 3", updatedAt = 3_000L, archived = false),
),
nextCursor = null,
)
},
)
assertEquals(1, pageRequests)
assertEquals(listOf("thread-3", "thread-2", "thread-1"), result.threads.map { it.id })
assertEquals(null, result.nextCursor)
}
@Test
fun seedInitialVisibleThreadsStopsWhenCursorRepeatsWithoutProgress() = runBlocking {
var pageRequests = 0
val result = seedInitialVisibleThreads(
initialPage = CodexThreadPage(
threads = listOf(
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = 2_000L, archived = false),
),
nextCursor = "cursor-1",
),
minimumVisibleThreads = 3,
loadNextPage = {
pageRequests += 1
CodexThreadPage(
threads = listOf(
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = 1_000L, archived = false),
),
nextCursor = "cursor-1",
)
},
)
assertEquals(1, pageRequests)
assertEquals(listOf("thread-1"), result.threads.map { it.id })
assertEquals("cursor-1", result.nextCursor)
}
@Test
fun seedInitialVisibleThreadsStopsOnCursorLoop() = runBlocking {
val requests = mutableListOf<String>()
val result = seedInitialVisibleThreads(
initialPage = CodexThreadPage(
threads = listOf(
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = 1_000L, archived = false),
),
nextCursor = "cursor-1",
),
minimumVisibleThreads = 3,
loadNextPage = { cursor ->
requests += cursor
if (cursor == "cursor-1") {
CodexThreadPage(
threads = listOf(
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = 1_000L, archived = false),
),
nextCursor = "cursor-2",
)
}
else {
CodexThreadPage(
threads = listOf(
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = 1_000L, archived = false),
),
nextCursor = "cursor-1",
)
}
},
)
assertEquals(listOf("cursor-1", "cursor-2"), requests)
assertTrue(result.threads.size == 1)
assertEquals("cursor-1", result.nextCursor)
}
}

View File

@@ -0,0 +1,895 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.intellij.agent.workbench.codex.common.CodexAppServerClient
import com.intellij.agent.workbench.codex.common.CodexAppServerException
import com.intellij.agent.workbench.codex.common.CodexProjectSessions
import com.intellij.agent.workbench.codex.common.CodexSessionsState
import com.intellij.agent.workbench.codex.common.CodexThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jetbrains.jewel.foundation.BorderColors
import org.jetbrains.jewel.foundation.DisabledAppearanceValues
import org.jetbrains.jewel.foundation.GlobalColors
import org.jetbrains.jewel.foundation.GlobalMetrics
import org.jetbrains.jewel.foundation.OutlineColors
import org.jetbrains.jewel.foundation.TextColors
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.theme.ThemeColorPalette
import org.jetbrains.jewel.foundation.theme.ThemeDefinition
import org.jetbrains.jewel.foundation.theme.ThemeIconData
import org.jetbrains.jewel.intui.standalone.theme.default
import org.jetbrains.jewel.ui.ComponentStyling
import org.jetbrains.jewel.ui.LocalTypography
import org.jetbrains.jewel.ui.Typography
import org.jetbrains.jewel.ui.icon.LocalNewUiChecker
import org.jetbrains.jewel.ui.icon.NewUiChecker
import org.jetbrains.jewel.ui.theme.BaseJewelTheme
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import java.nio.file.Files
class CodexSessionsToolWindowTest {
@get:Rule
val composeRule: ComposeContentTestRule = createComposeRule()
@Test
fun emptyStateIsShownWhenNoProjects() {
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = emptyList(), lastUpdatedAt = 1L),
onRefresh = {},
onOpenProject = {},
)
}
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.empty.global"))
.assertIsDisplayed()
}
@Test
fun projectsDoNotShowGlobalEmptyStateWhenNoThreadsLoadedYet() {
val projects = listOf(
CodexProjectSessions(
path = "/work/project-a",
name = "Project A",
isOpen = true,
),
CodexProjectSessions(
path = "/work/project-b",
name = "Project B",
isOpen = false,
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
)
}
composeRule.onNodeWithText("Project A").assertIsDisplayed()
composeRule.onNodeWithText("Project B").assertIsDisplayed()
composeRule.onAllNodesWithText(CodexSessionsBundle.message("toolwindow.empty.global"))
.assertCountEquals(0)
}
@Test
fun projectsShowThreadsWithoutInlineOpenAction() {
val now = 1_700_000_000_000L
val thread = CodexThread(id = "thread-1", title = "Thread One", updatedAt = now - 10 * 60 * 1000L, archived = false)
val projects = listOf(
CodexProjectSessions(
path = "/work/project-a",
name = "Project A",
isOpen = true,
threads = listOf(thread),
),
CodexProjectSessions(
path = "/work/project-b",
name = "Project B",
isOpen = false,
threads = emptyList(),
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
nowProvider = { now },
)
}
composeRule.onNodeWithText("Project A").assertIsDisplayed()
composeRule.onNodeWithText("Project B").assertIsDisplayed()
composeRule.onNodeWithText("Thread One").assertIsDisplayed()
composeRule.onNodeWithText("10m").assertIsDisplayed()
composeRule.onAllNodesWithText(CodexSessionsBundle.message("toolwindow.action.open"))
.assertCountEquals(0)
}
@Test
fun hoveringClosedProjectRowShowsPlusActionAndDoesNotInvokeOpenCallback() {
var createdPath: String? = null
var openedPath: String? = null
val projectPath = "/work/project-plus"
val projects = listOf(
CodexProjectSessions(
path = projectPath,
name = "Project Plus",
isOpen = false,
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = { openedPath = it },
onCreateThread = { createdPath = it },
)
}
val newThreadLabel = CodexSessionsBundle.message("toolwindow.action.new.thread")
composeRule.onAllNodesWithContentDescription(newThreadLabel).assertCountEquals(0)
composeRule.onNodeWithText("Project Plus")
.assertIsDisplayed()
.performMouseInput { moveTo(center) }
composeRule.onNodeWithContentDescription(newThreadLabel)
.assertIsDisplayed()
.performClick()
composeRule.runOnIdle {
assertEquals(projectPath, createdPath)
assertEquals(null, openedPath)
}
}
@Test
fun projectErrorShowsRetryAction() {
val projects = listOf(
CodexProjectSessions(
path = "/work/project-c",
name = "Project C",
isOpen = true,
errorMessage = "Failed",
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
)
}
composeRule.onNodeWithText("Failed").assertIsDisplayed()
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.error.retry"))
.assertIsDisplayed()
}
@Test
fun projectRefreshErrorKeepsVisibleThreads() {
val now = 1_700_000_000_000L
val projects = listOf(
CodexProjectSessions(
path = "/work/project-refresh-error",
name = "Project Refresh Error",
isOpen = true,
hasLoaded = true,
threads = listOf(
CodexThread(id = "thread-1", title = "Thread One", updatedAt = now - 10_000L, archived = false),
),
errorMessage = "Refresh failed",
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
nowProvider = { now },
)
}
composeRule.onNodeWithText("Thread One").assertIsDisplayed()
composeRule.onNodeWithText("Refresh failed").assertIsDisplayed()
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.error.retry"))
.assertIsDisplayed()
}
@Test
fun moreErrorRetryRequestsAdditionalThreads() {
var showMoreRequests = 0
val now = 1_700_000_000_000L
val projects = listOf(
CodexProjectSessions(
path = "/work/project-more-error",
name = "Project More Error",
isOpen = true,
hasLoaded = true,
threads = listOf(
CodexThread(id = "thread-1", title = "Thread One", updatedAt = now - 3_000L, archived = false),
CodexThread(id = "thread-2", title = "Thread Two", updatedAt = now - 2_000L, archived = false),
CodexThread(id = "thread-3", title = "Thread Three", updatedAt = now - 1_000L, archived = false),
),
nextThreadsCursor = "cursor-1",
loadMoreErrorMessage = CodexSessionsBundle.message("toolwindow.error.more"),
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
onShowMoreThreads = { showMoreRequests += 1 },
nowProvider = { now },
)
}
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.error.more")).assertIsDisplayed()
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.action.more")).assertIsDisplayed()
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.error.retry")).performClick()
composeRule.runOnIdle {
assertEquals(1, showMoreRequests)
}
}
@Test
fun openLoadedEmptyProjectIsExpandedByDefault() {
val projects = listOf(
CodexProjectSessions(
path = "/work/project-a",
name = "Project A",
isOpen = true,
hasLoaded = true,
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
)
}
composeRule.onNodeWithText("Project A").assertIsDisplayed()
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.empty.project"))
.assertIsDisplayed()
}
@Test
fun loadingProjectDoesNotShowEmptyProjectMessage() {
val projects = listOf(
CodexProjectSessions(
path = "/work/project-loading",
name = "Project Loading",
isOpen = true,
isLoading = true,
hasLoaded = true,
threads = emptyList(),
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
)
}
composeRule.onNodeWithText("Project Loading").assertIsDisplayed()
composeRule.onAllNodesWithText(CodexSessionsBundle.message("toolwindow.empty.project"))
.assertCountEquals(0)
}
@Test
fun emptyProjectWithCursorDoesNotShowMoreAction() {
val projects = listOf(
CodexProjectSessions(
path = "/work/project-empty-cursor",
name = "Project Empty Cursor",
isOpen = true,
hasLoaded = true,
threads = emptyList(),
nextThreadsCursor = "cursor-1",
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
)
}
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.empty.project")).assertIsDisplayed()
composeRule.onAllNodesWithText(CodexSessionsBundle.message("toolwindow.action.more")).assertCountEquals(0)
}
@Test
fun projectWithTwoThreadsAndCursorDoesNotShowMoreAction() {
val now = 1_700_000_000_000L
val projects = listOf(
CodexProjectSessions(
path = "/work/project-two-cursor",
name = "Project Two Cursor",
isOpen = true,
hasLoaded = true,
threads = listOf(
CodexThread(id = "thread-1", title = "Thread One", updatedAt = now - 2_000L, archived = false),
CodexThread(id = "thread-2", title = "Thread Two", updatedAt = now - 1_000L, archived = false),
),
nextThreadsCursor = "cursor-1",
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
nowProvider = { now },
)
}
composeRule.onNodeWithText("Thread One").assertIsDisplayed()
composeRule.onNodeWithText("Thread Two").assertIsDisplayed()
composeRule.onAllNodesWithText(CodexSessionsBundle.message("toolwindow.action.more")).assertCountEquals(0)
}
@Test
fun closedLoadedEmptyProjectIsExpandedByDefault() {
val now = 1_700_000_000_000L
val thread = CodexThread(id = "thread-1", title = "Thread One", updatedAt = now - 10 * 60 * 1000L, archived = false)
val projects = listOf(
CodexProjectSessions(
path = "/work/project-a",
name = "Project A",
isOpen = true,
threads = listOf(thread),
hasLoaded = true,
),
CodexProjectSessions(
path = "/work/project-b",
name = "Project B",
isOpen = false,
threads = emptyList(),
hasLoaded = true,
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
nowProvider = { now },
)
}
composeRule.onNodeWithText("Project B").assertIsDisplayed()
composeRule.onNodeWithText(CodexSessionsBundle.message("toolwindow.empty.project"))
.assertIsDisplayed()
}
@Test
fun collapsingProjectPersistsAcrossRemount() {
val uiState = InMemorySessionsTreeUiState()
val projects = listOf(
CodexProjectSessions(
path = "/work/project-collapsed",
name = "Project Collapsed",
isOpen = false,
hasLoaded = true,
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
treeUiState = uiState,
)
}
val emptyMessage = CodexSessionsBundle.message("toolwindow.empty.project")
composeRule.onNodeWithText(emptyMessage).assertIsDisplayed()
composeRule.onNodeWithText("Project Collapsed")
.assertIsDisplayed()
.performMouseInput { doubleClick() }
composeRule.onAllNodesWithText(emptyMessage).assertCountEquals(0)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = CodexSessionsState(projects = projects),
onRefresh = {},
onOpenProject = {},
treeUiState = uiState,
)
}
composeRule.onNodeWithText("Project Collapsed").assertIsDisplayed()
composeRule.onAllNodesWithText(emptyMessage).assertCountEquals(0)
}
@Test
fun projectShowsMostRecentThreeThreadsAndMoreRequestsAdditionalThreads() {
val now = 1_700_000_000_000L
val uiState = InMemorySessionsTreeUiState()
var showMoreRequests = 0
val threads = listOf(
CodexThread(id = "thread-4", title = "Thread 4", updatedAt = now - 4_000L, archived = false),
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = now - 1_000L, archived = false),
CodexThread(id = "thread-5", title = "Thread 5", updatedAt = now - 5_000L, archived = false),
CodexThread(id = "thread-3", title = "Thread 3", updatedAt = now - 3_000L, archived = false),
CodexThread(id = "thread-2", title = "Thread 2", updatedAt = now - 2_000L, archived = false),
)
val initialState = CodexSessionsState(
projects = listOf(
CodexProjectSessions(
path = "/work/project-more",
name = "Project More",
isOpen = true,
hasLoaded = true,
threads = threads,
),
)
)
val renderState = mutableStateOf(initialState)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = renderState.value,
onRefresh = {},
onOpenProject = {},
onShowMoreThreads = { path ->
showMoreRequests += 1
uiState.incrementVisibleThreadCount(path, delta = 3)
val current = renderState.value
renderState.value = current.copy(lastUpdatedAt = (current.lastUpdatedAt ?: 0L) + 1L)
},
treeUiState = uiState,
)
}
composeRule.onNodeWithText("Thread 1").assertIsDisplayed()
composeRule.onNodeWithText("Thread 2").assertIsDisplayed()
composeRule.onNodeWithText("Thread 3").assertIsDisplayed()
composeRule.onAllNodesWithText("Thread 4").assertCountEquals(0)
composeRule.onAllNodesWithText("Thread 5").assertCountEquals(0)
val moreLabel = CodexSessionsBundle.message("toolwindow.action.more")
composeRule.onNodeWithText(moreLabel).assertIsDisplayed()
composeRule.onNodeWithText(moreLabel).performClick()
composeRule.waitForIdle()
composeRule.runOnIdle {
assertEquals(1, showMoreRequests)
}
}
@Test
fun savedThreadPreviewRestoresBeforeRefreshWithMockBackend() {
verifySavedThreadPreviewRestore(createMockBackendDefinition())
}
@Test
fun savedThreadPreviewRestoresBeforeRefreshWithRealBackend() {
verifySavedThreadPreviewRestore(createRealBackendDefinition())
}
@Test
fun moreLoadsNextBackendPageWithMockBackend() {
verifyMoreLoadsNextBackendPage(createMockBackendDefinition())
}
@Test
fun moreLoadsNextBackendPageWithRealBackend() {
verifyMoreLoadsNextBackendPage(createRealBackendDefinition())
}
private fun verifySavedThreadPreviewRestore(backend: CodexBackend) {
val snapshot = loadBackendThreadsSnapshot(backend = backend)
val savedTitle = "Saved Preview (${backend.name})"
val projectPath = snapshot.projectPath
val uiState = InMemorySessionsTreeUiState()
uiState.setOpenProjectThreadPreviews(
path = projectPath,
threads = listOf(
CodexThread(
id = "saved-preview-${backend.name}",
title = savedTitle,
updatedAt = 1L,
archived = false,
),
),
)
val cachedThreads = uiState.getOpenProjectThreadPreviews(projectPath).orEmpty()
val renderState = mutableStateOf(
CodexSessionsState(
projects = listOf(
CodexProjectSessions(
path = projectPath,
name = "Project ${backend.name}",
isOpen = true,
isLoading = true,
hasLoaded = cachedThreads.isNotEmpty(),
threads = cachedThreads,
),
),
),
)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = renderState.value,
onRefresh = {},
onOpenProject = {},
treeUiState = uiState,
)
}
composeRule.onNodeWithText(savedTitle).assertIsDisplayed()
composeRule.runOnIdle {
val loadedProject = renderState.value.projects.single().copy(
isLoading = false,
hasLoaded = true,
threads = snapshot.threads,
)
renderState.value = CodexSessionsState(
projects = listOf(loadedProject),
lastUpdatedAt = System.currentTimeMillis(),
)
}
composeRule.waitForIdle()
composeRule.onNodeWithText(snapshot.threads.first().title).assertIsDisplayed()
composeRule.onAllNodesWithText(savedTitle).assertCountEquals(0)
}
private fun verifyMoreLoadsNextBackendPage(backend: CodexBackend) {
val snapshot = loadBackendPagingSnapshot(backend = backend)
val uiState = InMemorySessionsTreeUiState()
var showMoreRequests = 0
val initialState = CodexSessionsState(
projects = listOf(
CodexProjectSessions(
path = snapshot.projectPath,
name = "Project ${backend.name}",
isOpen = true,
hasLoaded = true,
threads = snapshot.firstPageThreads,
nextThreadsCursor = snapshot.firstPageNextCursor,
),
),
)
val renderState = mutableStateOf(initialState)
composeRule.setContentWithTheme {
codexSessionsToolWindowContent(
state = renderState.value,
onRefresh = {},
onOpenProject = {},
onShowMoreThreads = { path ->
showMoreRequests += 1
uiState.incrementVisibleThreadCount(path, delta = 3)
val current = renderState.value.projects.single()
val mergedThreads = (current.threads + snapshot.secondPageThreads)
.associateBy { it.id }
.values
.sortedByDescending { it.updatedAt }
renderState.value = renderState.value.copy(
projects = listOf(
current.copy(
threads = mergedThreads,
nextThreadsCursor = snapshot.secondPageNextCursor,
),
),
lastUpdatedAt = (renderState.value.lastUpdatedAt ?: 0L) + 1L,
)
},
treeUiState = uiState,
)
}
val firstLoadedFromSecondPage = snapshot.secondPageThreads.first().title
composeRule.onAllNodesWithText(firstLoadedFromSecondPage).assertCountEquals(0)
val moreLabel = CodexSessionsBundle.message("toolwindow.action.more")
composeRule.onNodeWithText(moreLabel).assertIsDisplayed()
composeRule.onNodeWithText(moreLabel).performClick()
composeRule.waitForIdle()
composeRule.runOnIdle {
assertEquals(1, showMoreRequests)
}
composeRule.onNodeWithText(firstLoadedFromSecondPage).assertIsDisplayed()
}
private fun loadBackendThreadsSnapshot(
backend: CodexBackend,
): BackendThreadsSnapshot {
return withBackendClient(backend = backend) { client, projectPath, _ ->
val threads = ensureMinimumThreads(client, backendName = backend.name, minimumThreadCount = 1)
BackendThreadsSnapshot(projectPath = projectPath, threads = threads)
}
}
private fun loadBackendPagingSnapshot(
backend: CodexBackend,
): BackendPagingSnapshot {
return withBackendClient(backend = backend) { client, projectPath, _ ->
ensureMinimumThreads(client, backendName = backend.name, minimumThreadCount = 4)
val firstPage = client.listThreadsPage(archived = false, cursor = null, limit = 3)
val firstCursor = firstPage.nextCursor
assumeTrue(
"${backend.name} backend must return a second page for this scenario",
!firstCursor.isNullOrBlank(),
)
val nonNullFirstCursor = firstCursor ?: throw AssertionError("Missing cursor after assumption")
val secondPage = client.listThreadsPage(archived = false, cursor = nonNullFirstCursor, limit = 3)
assumeTrue(
"${backend.name} backend second page must contain at least one thread",
secondPage.threads.isNotEmpty(),
)
BackendPagingSnapshot(
projectPath = projectPath,
firstPageThreads = firstPage.threads,
firstPageNextCursor = nonNullFirstCursor,
secondPageThreads = secondPage.threads,
secondPageNextCursor = secondPage.nextCursor,
)
}
}
private fun <T> withBackendClient(
backend: CodexBackend,
block: suspend (client: CodexAppServerClient, projectPath: String, scope: CoroutineScope) -> T,
): T {
return runBlocking {
withContext(Dispatchers.IO) {
val maxAttempts = if (backend.name == "mock") 3 else 1
var lastTerminationError: Throwable? = null
repeat(maxAttempts) { attempt ->
val rootDir = Files.createTempDirectory("codex-sessions-${backend.name}-")
val projectDir = rootDir.resolve("project")
Files.createDirectories(projectDir)
val projectPath = projectDir.toString()
val configPath = projectDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = List(6) { index ->
val updatedAt = 1_700_000_000_000L + (6 - index) * 1_000L
ThreadSpec(
id = "seed-${index + 1}",
title = "Seed ${index + 1}",
cwd = projectPath,
updatedAt = updatedAt,
archived = false,
)
},
)
val backendDir = rootDir.resolve("backend")
Files.createDirectories(backendDir)
val client = backend.createClient(
scope = this,
tempDir = backendDir,
configPath = configPath,
workingDirectory = projectDir,
)
try {
return@withContext block(client, projectPath, this)
}
catch (t: Throwable) {
val shouldRetry = backend.name == "mock" && attempt < maxAttempts - 1 && isAppServerTermination(t)
if (!shouldRetry) throw t
lastTerminationError = t
}
finally {
client.shutdown()
}
}
throw AssertionError("Mock backend app-server terminated repeatedly", lastTerminationError)
}
}
}
private fun isAppServerTermination(throwable: Throwable): Boolean {
var current: Throwable? = throwable
while (current != null) {
if (current is CodexAppServerException) {
return true
}
current = current.cause
}
return false
}
private suspend fun ensureMinimumThreads(
client: CodexAppServerClient,
backendName: String,
minimumThreadCount: Int,
): List<CodexThread> {
var threads = client.listThreads(archived = false)
if (backendName == "real") {
assumeTrue(
"real backend must provide at least $minimumThreadCount active threads; tests do not create real threads",
threads.size >= minimumThreadCount,
)
return threads
}
if (threads.size >= minimumThreadCount) return threads
val missing = minimumThreadCount - threads.size
repeat(missing) {
client.createThread()
}
threads = client.listThreads(archived = false)
assumeTrue(
"$backendName backend must provide at least $minimumThreadCount active threads",
threads.size >= minimumThreadCount,
)
return threads
}
private data class BackendThreadsSnapshot(
val projectPath: String,
val threads: List<CodexThread>,
)
private data class BackendPagingSnapshot(
val projectPath: String,
val firstPageThreads: List<CodexThread>,
val firstPageNextCursor: String,
val secondPageThreads: List<CodexThread>,
val secondPageNextCursor: String?,
)
}
private fun ComposeContentTestRule.setContentWithTheme(content: @Composable () -> Unit) {
setContent {
BaseJewelTheme(createTestThemeDefinition(), ComponentStyling.default()) {
CompositionLocalProvider(
LocalTypography provides TestTypography,
LocalNewUiChecker provides TestNewUiChecker,
) {
content()
}
}
}
}
private fun createTestThemeDefinition(): ThemeDefinition {
return ThemeDefinition(
name = "Test",
isDark = false,
globalColors =
GlobalColors(
borders = BorderColors(normal = Color.Black, focused = Color.Black, disabled = Color.Black),
outlines =
OutlineColors(
focused = Color.Black,
focusedWarning = Color.Black,
focusedError = Color.Black,
warning = Color.Black,
error = Color.Black,
),
text =
TextColors(
normal = Color.Black,
selected = Color.Black,
disabled = Color.Black,
disabledSelected = Color.Black,
info = Color.Black,
error = Color.Black,
warning = Color.Black,
),
panelBackground = Color.White,
toolwindowBackground = Color.White,
),
globalMetrics = GlobalMetrics(outlineWidth = 10.dp, rowHeight = 24.dp),
defaultTextStyle = TextStyle(fontSize = 13.sp),
editorTextStyle = TextStyle(fontSize = 13.sp),
consoleTextStyle = TextStyle(fontSize = 13.sp),
contentColor = Color.Black,
colorPalette = ThemeColorPalette.Empty,
iconData = ThemeIconData.Empty,
disabledAppearanceValues = DisabledAppearanceValues(brightness = 33, contrast = -35, alpha = 100),
)
}
private object TestTypography : Typography {
@get:Composable
override val labelTextStyle: TextStyle
get() = JewelTheme.defaultTextStyle
@get:Composable
override val labelTextSize
get() = JewelTheme.defaultTextStyle.fontSize
@get:Composable
override val h0TextStyle: TextStyle
get() = labelTextStyle
@get:Composable
override val h1TextStyle: TextStyle
get() = labelTextStyle
@get:Composable
override val h2TextStyle: TextStyle
get() = labelTextStyle
@get:Composable
override val h3TextStyle: TextStyle
get() = labelTextStyle
@get:Composable
override val h4TextStyle: TextStyle
get() = labelTextStyle
@get:Composable
override val regular: TextStyle
get() = labelTextStyle
@get:Composable
override val medium: TextStyle
get() = labelTextStyle
@get:Composable
override val small: TextStyle
get() = labelTextStyle
@get:Composable
override val editorTextStyle: TextStyle
get() = JewelTheme.editorTextStyle
@get:Composable
override val consoleTextStyle: TextStyle
get() = JewelTheme.consoleTextStyle
}
private object TestNewUiChecker : NewUiChecker {
override fun isNewUi(): Boolean = true
}

View File

@@ -0,0 +1,64 @@
package com.intellij.agent.workbench.sessions
import com.intellij.agent.workbench.codex.common.CodexThread
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CodexSessionsTreeUiStateServiceTest {
@Test
fun projectCollapseStateRoundTrip() {
val uiState = CodexSessionsTreeUiStateService()
assertTrue(uiState.setProjectCollapsed("/work/project-a", collapsed = true))
assertTrue(uiState.isProjectCollapsed("/work/project-a"))
assertFalse(uiState.setProjectCollapsed("/work/project-a", collapsed = true))
assertTrue(uiState.setProjectCollapsed("/work/project-a", collapsed = false))
assertFalse(uiState.isProjectCollapsed("/work/project-a"))
}
@Test
fun visibleThreadCountStateRoundTrip() {
val uiState = CodexSessionsTreeUiStateService()
assertEquals(DEFAULT_VISIBLE_THREAD_COUNT, uiState.getVisibleThreadCount("/work/project-a"))
assertTrue(uiState.incrementVisibleThreadCount("/work/project-a", delta = 3))
assertEquals(DEFAULT_VISIBLE_THREAD_COUNT + 3, uiState.getVisibleThreadCount("/work/project-a"))
assertTrue(uiState.resetVisibleThreadCount("/work/project-a"))
assertEquals(DEFAULT_VISIBLE_THREAD_COUNT, uiState.getVisibleThreadCount("/work/project-a"))
assertFalse(uiState.resetVisibleThreadCount("/work/project-a"))
}
@Test
fun pathNormalizationIsAppliedToStoredState() {
val uiState = CodexSessionsTreeUiStateService()
uiState.setProjectCollapsed("/work/project-a/", collapsed = true)
assertTrue(uiState.isProjectCollapsed("/work/project-a"))
uiState.incrementVisibleThreadCount("/work/project-b/", delta = 3)
assertEquals(DEFAULT_VISIBLE_THREAD_COUNT + 3, uiState.getVisibleThreadCount("/work/project-b"))
}
@Test
fun openProjectThreadPreviewCacheRoundTrip() {
val uiState = CodexSessionsTreeUiStateService()
val threads = listOf(
CodexThread(id = "thread-1", title = "Thread 1", updatedAt = 5L, archived = false),
CodexThread(id = "thread-2", title = "Thread 2", updatedAt = 10L, archived = false),
)
assertTrue(uiState.setOpenProjectThreadPreviews("/work/project-a/", threads))
val cached = uiState.getOpenProjectThreadPreviews("/work/project-a")
assertEquals(listOf("thread-2", "thread-1"), cached?.map { it.id })
assertTrue(uiState.retainOpenProjectThreadPreviews(setOf("/work/project-b")))
assertNull(uiState.getOpenProjectThreadPreviews("/work/project-a"))
}
}

View File

@@ -0,0 +1,377 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.intellij.agent.workbench.codex.common.forEachObjectField
import com.intellij.agent.workbench.codex.common.readLongOrNull
import com.intellij.agent.workbench.codex.common.readStringOrNull
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
private data class ThreadEntry(
val id: String,
val title: String?,
val preview: String?,
val name: String?,
val summary: String?,
val cwd: String?,
var updatedAt: Long?,
var updatedAtField: String?,
val createdAt: Long?,
val createdAtField: String?,
var archived: Boolean,
)
private data class Request(
val id: String,
val method: String,
val params: RequestParams,
)
private data class RequestParams(
val id: String? = null,
val archived: Boolean? = null,
val cursor: String? = null,
val limit: Int? = null,
)
internal object CodexTestAppServer {
private val jsonFactory = JsonFactory()
private const val CWD_MARKER_ENV = "CODEX_TEST_CWD_MARKER"
private const val ERROR_METHOD_ENV = "CODEX_TEST_ERROR_METHOD"
private const val ERROR_MESSAGE_ENV = "CODEX_TEST_ERROR_MESSAGE"
@JvmStatic
fun main(args: Array<String>) {
val configPath = args.firstOrNull()?.let(Path::of)
?: error("Expected config path argument")
val threads = loadThreads(configPath)
val errorMethod = readEnv(ERROR_METHOD_ENV)
val errorMessage = readEnv(ERROR_MESSAGE_ENV)
readEnv(CWD_MARKER_ENV)?.let(::writeWorkingDirectoryMarker)
val reader = BufferedReader(InputStreamReader(System.`in`, StandardCharsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(System.out, StandardCharsets.UTF_8))
while (true) {
val line = reader.readLine() ?: break
val payload = line.trim()
if (payload.isEmpty()) continue
val request = try {
parseRequest(payload)
}
catch (_: Throwable) {
null
}
if (request == null) continue
if (errorMethod != null && request.method == errorMethod) {
writeResponse(writer, request.id, ::writeEmptyObject, errorMessage = errorMessage ?: "Forced error")
continue
}
when (request.method) {
"initialize" -> writeResponse(writer, request.id, ::writeEmptyObject)
"thread/start" -> {
val startedThread = startThread(threads)
writeResponse(writer, request.id, resultWriter = { generator ->
generator.writeStartObject()
generator.writeFieldName("thread")
writeThreadObject(generator, startedThread)
generator.writeEndObject()
})
}
"thread/list" -> writeResponse(writer, request.id, resultWriter = { generator ->
val archived = request.params.archived ?: false
writeThreadList(
generator = generator,
threads = threads,
archived = archived,
cursor = request.params.cursor,
limit = request.params.limit,
)
})
"thread/archive" -> {
updateArchive(request.params.id, threads, archive = true)
writeResponse(writer, request.id, ::writeEmptyObject)
}
"thread/unarchive" -> {
updateArchive(request.params.id, threads, archive = false)
writeResponse(writer, request.id, ::writeEmptyObject)
}
else -> writeResponse(writer, request.id, ::writeEmptyObject, errorMessage = "Unknown method: ${request.method}")
}
}
}
private fun parseRequest(payload: String): Request? {
jsonFactory.createParser(payload).use { parser ->
if (parser.nextToken() != JsonToken.START_OBJECT) return null
var id: String? = null
var method: String? = null
var paramsId: String? = null
var paramsArchived: Boolean? = null
var paramsCursor: String? = null
var paramsLimit: Int? = null
forEachObjectField(parser) { fieldName ->
when (fieldName) {
"id" -> id = readStringOrNull(parser)
"method" -> method = readStringOrNull(parser)
"params" -> {
if (parser.currentToken == JsonToken.START_OBJECT) {
forEachObjectField(parser) { paramName ->
when (paramName) {
"id" -> paramsId = readStringOrNull(parser)
"archived" -> paramsArchived = readBooleanOrNull(parser)
"cursor" -> paramsCursor = readStringOrNull(parser)
"limit" -> paramsLimit = readLongOrNull(parser)?.toInt()
else -> parser.skipChildren()
}
true
}
}
else {
parser.skipChildren()
}
}
else -> parser.skipChildren()
}
true
}
val requestId = id?.takeIf { it.isNotBlank() } ?: return null
val requestMethod = method?.takeIf { it.isNotBlank() } ?: return null
return Request(requestId, requestMethod, RequestParams(paramsId, paramsArchived, paramsCursor, paramsLimit))
}
}
private fun loadThreads(path: Path): MutableList<ThreadEntry> {
if (!Files.exists(path)) return mutableListOf()
Files.newBufferedReader(path, StandardCharsets.UTF_8).use { reader ->
jsonFactory.createParser(reader).use { parser ->
if (parser.nextToken() != JsonToken.START_OBJECT) return mutableListOf()
val result = mutableListOf<ThreadEntry>()
forEachObjectField(parser) { fieldName ->
if (fieldName == "threads" && parser.currentToken == JsonToken.START_ARRAY) {
parseThreadsArray(parser, result)
}
else {
parser.skipChildren()
}
true
}
return result
}
}
}
private fun parseThreadsArray(parser: JsonParser, result: MutableList<ThreadEntry>) {
while (true) {
val token = parser.nextToken() ?: return
if (token == JsonToken.END_ARRAY) return
if (token == JsonToken.START_OBJECT) {
parseThreadEntry(parser)?.let(result::add)
}
else {
parser.skipChildren()
}
}
}
private fun parseThreadEntry(parser: JsonParser): ThreadEntry? {
var id: String? = null
var title: String? = null
var preview: String? = null
var name: String? = null
var summary: String? = null
var cwd: String? = null
var updatedAt: Long? = null
var updatedAtField: String? = null
var createdAt: Long? = null
var createdAtField: String? = null
var archived = false
forEachObjectField(parser) { fieldName ->
when (fieldName) {
"id" -> id = readStringOrNull(parser)
"title" -> title = readStringOrNull(parser)
"preview" -> preview = readStringOrNull(parser)
"name" -> name = readStringOrNull(parser)
"summary" -> summary = readStringOrNull(parser)
"cwd" -> cwd = readStringOrNull(parser)
"updated_at" -> {
updatedAt = readLongOrNull(parser)
updatedAtField = "updated_at"
}
"updatedAt" -> {
updatedAt = readLongOrNull(parser)
updatedAtField = "updatedAt"
}
"created_at" -> {
createdAt = readLongOrNull(parser)
createdAtField = "created_at"
}
"createdAt" -> {
createdAt = readLongOrNull(parser)
createdAtField = "createdAt"
}
"archived" -> archived = readBooleanOrNull(parser) ?: false
else -> parser.skipChildren()
}
true
}
val threadId = id ?: return null
return ThreadEntry(
id = threadId,
title = title,
preview = preview,
name = name,
summary = summary,
cwd = cwd,
updatedAt = updatedAt,
updatedAtField = updatedAtField,
createdAt = createdAt,
createdAtField = createdAtField,
archived = archived,
)
}
private fun writeResponse(
writer: BufferedWriter,
id: String,
resultWriter: (JsonGenerator) -> Unit,
errorMessage: String? = null,
) {
val generator = jsonFactory.createGenerator(writer)
generator.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET)
generator.writeStartObject()
generator.writeStringField("id", id)
if (errorMessage != null) {
generator.writeFieldName("error")
generator.writeStartObject()
generator.writeStringField("message", errorMessage)
generator.writeEndObject()
}
else {
generator.writeFieldName("result")
resultWriter(generator)
}
generator.writeEndObject()
generator.close()
writer.newLine()
writer.flush()
}
private fun writeEmptyObject(generator: JsonGenerator) {
generator.writeStartObject()
generator.writeEndObject()
}
private fun writeThreadList(
generator: JsonGenerator,
threads: List<ThreadEntry>,
archived: Boolean,
cursor: String?,
limit: Int?,
) {
val sorted = threads
.filter { it.archived == archived }
.sortedByDescending { it.updatedAt ?: 0L }
val pageStart = cursor?.toIntOrNull()?.coerceAtLeast(0) ?: 0
val pageLimit = (limit ?: sorted.size).coerceAtLeast(1)
val pageItems = sorted.drop(pageStart).take(pageLimit)
val nextOffset = pageStart + pageItems.size
val nextCursor = if (nextOffset < sorted.size) nextOffset.toString() else null
generator.writeStartObject()
generator.writeFieldName("data")
generator.writeStartArray()
pageItems.forEach { thread ->
writeThreadObject(generator, thread)
}
generator.writeEndArray()
if (nextCursor != null) {
generator.writeStringField("nextCursor", nextCursor)
}
generator.writeEndObject()
}
private fun writeThreadObject(generator: JsonGenerator, thread: ThreadEntry) {
generator.writeStartObject()
generator.writeStringField("id", thread.id)
thread.title?.let { generator.writeStringField("title", it) }
thread.preview?.let { generator.writeStringField("preview", it) }
thread.name?.let { generator.writeStringField("name", it) }
thread.summary?.let { generator.writeStringField("summary", it) }
thread.cwd?.let { generator.writeStringField("cwd", it) }
thread.updatedAt?.let { updatedAt ->
val field = thread.updatedAtField?.takeIf { it.isNotBlank() } ?: "updated_at"
generator.writeNumberField(field, updatedAt)
}
thread.createdAt?.let { createdAt ->
val field = thread.createdAtField?.takeIf { it.isNotBlank() } ?: "created_at"
generator.writeNumberField(field, createdAt)
}
generator.writeEndObject()
}
private fun startThread(threads: MutableList<ThreadEntry>): ThreadEntry {
val now = System.currentTimeMillis()
val id = "thread-start-$now"
val cwd = threads.firstOrNull { !it.cwd.isNullOrBlank() }?.cwd ?: System.getProperty("user.dir")
val thread = ThreadEntry(
id = id,
title = "Thread ${id.takeLast(8)}",
preview = "",
name = null,
summary = null,
cwd = cwd,
updatedAt = now,
updatedAtField = "updated_at",
createdAt = now,
createdAtField = "created_at",
archived = false,
)
threads.add(thread)
return thread
}
private fun updateArchive(id: String?, threads: MutableList<ThreadEntry>, archive: Boolean) {
if (id == null) return
val thread = threads.firstOrNull { it.id == id } ?: return
thread.archived = archive
thread.updatedAt = System.currentTimeMillis()
if (thread.updatedAtField.isNullOrBlank()) {
thread.updatedAtField = "updated_at"
}
}
private fun readEnv(name: String): String? {
return System.getenv(name)?.trim()?.takeIf { it.isNotEmpty() }
}
private fun writeWorkingDirectoryMarker(marker: String) {
try {
val markerPath = Path.of(marker)
val cwd = System.getProperty("user.dir")
Files.writeString(markerPath, cwd, StandardCharsets.UTF_8)
}
catch (_: Throwable) {
}
}
private fun readBooleanOrNull(parser: JsonParser): Boolean? {
return when (parser.currentToken) {
JsonToken.VALUE_TRUE -> true
JsonToken.VALUE_FALSE -> false
JsonToken.VALUE_NUMBER_INT -> parser.intValue != 0
JsonToken.VALUE_STRING -> parser.text.toBoolean()
JsonToken.VALUE_NULL -> null
else -> {
parser.skipChildren()
null
}
}
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tessl
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,68 @@
# Agent Workbench Spec Format
This is the single spec format for this plugin. Specs live in `spec/` and must be Markdown files ending in `.spec.md`.
## Required Structure
- YAML frontmatter with `name`, `description`, and `targets` (at least one file path or glob).
- An H1 title matching the frontmatter `name`.
- A metadata block with `Status` and `Date` (ISO-8601).
- A concise summary of the behavior and requirements being specified.
- `[@test]` links placed adjacent to the requirements they verify.
## Template
```markdown
---
name: Agent Threads Tool Window
description: Requirements for the Agent Threads tool window and app-server integration.
targets:
- ../sessions/src/*.kt
- ../sessions/resources/intellij.agent.workbench.sessions.xml
- ../sessions/resources/messages/CodexSessionsBundle.properties
---
# Agent Threads Tool Window
Status: Draft
Date: 2026-02-03
## Summary
Provide a concise description of the feature, scope, and intent.
## Goals
- Primary outcomes the feature must deliver.
## Non-goals
- Explicit exclusions to avoid scope creep.
## Requirements
- Each requirement must be testable and specific.
[@test] ../sessions/testSrc/CodexSessionsToolWindowTest.kt
## User Experience
- Describe UI states and interactions.
- Keep user-visible strings in `.properties`.
## Data & Backend
- Protocols, payloads, ordering, paging, and error behavior.
## Error Handling
- Failure modes and user-facing recovery actions.
## Testing / Local Run
- List non-standard commands or environment setup.
## Open Questions / Risks
- Decisions pending or known risks.
```
## Guidance
- Use must/should/may language; avoid ambiguous phrasing.
- Keep specs small; split by feature or subsystem to stay within context limits.
- Include concrete examples for data shapes, UI states, or error copy when needed.
- Keep `targets` and `[@test]` paths accurate and up to date.
- Treat the spec as the source of truth during review and implementation.
## References
- Adapted from the Tessl spec-driven development tile (see `LICENSE`).
- Informed by Addy Osmani's "How to write a good spec for AI agents".

View File

@@ -0,0 +1,65 @@
---
name: Agent Chat Editor (Dedicated Frame + Current Project Frame)
description: Requirements for opening Agent chat as an editor tab via AsyncFileEditorProvider with a terminal-backed UI and dedicated-frame mode.
targets:
- ../chat/src/*.kt
- ../chat/resources/intellij.agent.workbench.chat.xml
- ../chat/resources/messages/CodexChatBundle.properties
- ../plugin/resources/META-INF/plugin.xml
- ../plugin-content.yaml
- ../sessions/src/*.kt
---
# Agent Chat Editor (Dedicated Frame + Current Project Frame)
Status: Draft
Date: 2026-02-04
## Summary
Provide an Agent chat editor that opens as a file editor tab via `AsyncFileEditorProvider`. The default mode opens chat in a dedicated AI-chat frame backed by a hidden internal project. A user-facing advanced setting allows switching to current-project-frame mode.
Dedicated-frame lifecycle and routing details are defined in `spec/codex-dedicated-frame.spec.md`.
## Goals
- Open chat editors in a dedicated frame by default, with a fallback mode for the current project frame.
- Reuse one editor tab per thread (and per sub-agent when applicable).
- Use the reworked terminal frontend as the initial UI surface.
- Keep the entry point and navigation anchored to the Agent Threads tool window.
## Non-goals
- Implementing a Compose-based app-server protocol UI.
## Requirements
- The `intellij.agent.workbench` content module must register a `fileEditorProvider` for Agent chat editors.
- The chat editor must be opened via `AsyncFileEditorProvider` and use the reworked terminal frontend (`TerminalToolWindowTabsManager`) with `shouldAddToToolWindow(false)`.
- Chat editors reuse an existing editor tab for the same `threadId` (and `subAgentId` if present).
- Advanced setting `agent.workbench.chat.open.in.dedicated.frame` controls target frame selection (default `true`).
- When the setting is enabled, chat opens in a dedicated frame project and the source project remains closed if it is currently closed.
- When the setting is disabled, chat opens in the source project frame and closed source projects are opened first.
- Clicking a thread row opens its chat editor. Clicking a sub-agent row opens a separate chat editor tab scoped to that sub-agent.
- The editor tab title must use the thread title (fallback to `Agent Chat` when blank).
- The shell command used to start chat sessions is `codex resume <threadId>`.
For dedicated-frame ownership, reuse policy, and filtering behavior, see `spec/codex-dedicated-frame.spec.md`.
## User Experience
- Single click on a thread row opens the chat editor.
- Single click on a sub-agent row opens a separate chat editor tab for that sub-agent.
- Editor tab name is the thread title; editor icon uses an Agent/communication glyph.
- By default, chat editor opens in a dedicated frame.
- Users can disable dedicated-frame mode from Advanced Settings to restore current-project-frame behavior.
## Data & Backend
- Chat terminal sessions start in the project working directory.
- The Codex CLI invocation is `codex resume <threadId>`.
- Do not override `CODEX_HOME`; rely on the default environment so project `.codex/` config is discovered via `cwd`.
## Error Handling
- If the project path is invalid or project opening fails, do not open a chat editor tab.
## Testing / Local Run
- Add tests for tab reuse per thread/sub-agent and tab title resolution.
- Run: `./tests.cmd -Dintellij.build.test.patterns=<FQN>` for chat module tests when added.
## Open Questions / Risks
- Dedicated-frame storage location policy may be revisited to align with welcome-project conventions.

View File

@@ -0,0 +1,83 @@
---
name: Agent Chat Dedicated Frame
description: Requirements for the dedicated AI-chat frame used by Agent Workbench chat routing.
targets:
- ../sessions/src/*.kt
- ../sessions/resources/intellij.agent.workbench.sessions.xml
- ../sessions/resources/messages/CodexSessionsBundle.properties
- ../chat/src/*.kt
- ../sessions/testSrc/*.kt
---
# Agent Chat Dedicated Frame
Status: Draft
Date: 2026-02-07
## Summary
Define dedicated-frame behavior for Agent chat opening. By default, chat opens in a dedicated frame backed by a hidden internal project. Users can switch to current-project-frame mode via Advanced Settings and a Sessions gear toggle.
## Rationale
- A dedicated frame supports AI-task orchestration separately from regular project editing.
- It avoids forcing source projects to open when users only need chat context.
- It keeps chat frame policy centralized instead of duplicating logic across Sessions and Chat specs.
## Goals
- Default chat routing to a single reusable dedicated frame per IDE instance.
- User-configurable mode switching between dedicated frame and current project frame.
- Stable dedicated-frame lifecycle with predictable reuse and focus behavior.
- Dedicated frame project must not appear in the Sessions tree.
## Non-goals
- Implementing `welcomeScreenProjectProvider` integration.
- Creating multiple dedicated frames per project/thread/sub-agent.
- Replacing terminal-backed chat editor with Compose app-server UI.
## Requirements
- Advanced setting key `agent.workbench.chat.open.in.dedicated.frame` exists, defaults to `true`, and is exposed in Advanced Settings.
- Sessions gear menu includes toggle action `AgentWorkbenchSessions.ToggleDedicatedFrame` that updates the same setting.
- Dedicated mode (`true`):
- Thread/sub-agent click opens chat in the dedicated frame project.
- If the dedicated frame project is not open, it is opened in a new frame and reused afterwards.
- Source project is not opened automatically when closed.
- Current-project mode (`false`):
- Preserve legacy behavior: open chat in source project frame.
- If source project is closed, open it first and then open chat.
- Dedicated frame project is hidden from recent projects metadata.
- Dedicated frame project is excluded from Sessions project registry (both open and recent enumerations).
- Chat terminal working directory remains the source project path regardless of frame mode.
- Implementation must stay independent from `welcomeScreenProjectProvider` because that provider model is singleton-like across products.
## User Experience
- Default click on thread opens dedicated chat frame.
- Toggling `Open Chat in Dedicated Frame` immediately affects subsequent opens.
- Dedicated frame window receives focus after opening chat.
- Sessions list never shows the dedicated frame as a project node.
## Data & Backend
- Mode state is stored via Advanced Settings.
- Dedicated frame project path is managed by `AgentWorkbenchDedicatedFrameProjectManager`.
- Chat command remains `codex resume <threadId>` with source-project `cwd`.
## Error Handling
- If dedicated frame project path cannot be prepared/opened, log warning and do not open chat tab.
- Invalid source project path should not crash routing logic.
## Testing / Local Run
- Verify gear actions include toggle + refresh + open.
- Verify toggle flips advanced setting value.
- Verify dedicated mode opens/reuses dedicated frame project.
- Verify current-project mode preserves legacy flow.
- Verify dedicated frame project is filtered from Sessions list.
- Run tests using:
- `./tests.cmd -Dintellij.build.test.patterns=com.intellij.agent.workbench.sessions.CodexSessionsGearActionsTest`
- `./tests.cmd -Dintellij.build.test.patterns=com.intellij.agent.workbench.sessions.CodexSessionsToolWindowTest`
## Open Questions / Risks
- Dedicated frame project path base policy can be revised later to align with welcome-project storage conventions.
- Additional service-level tests for dedicated routing behavior may be needed.
## References
- `spec/codex-chat-editor.spec.md`
- `spec/codex-sessions.spec.md`
- `community/platform/platform-impl/src/com/intellij/openapi/wm/ex/WelcomeScreenProjectProvider.kt`

View File

@@ -0,0 +1,73 @@
---
name: Agent Threads Testing
description: Contract and UI test coverage for the Agent Threads tool window.
targets:
- ../sessions/testSrc/CodexAppServerClientTest.kt
- ../sessions/testSrc/CodexAppServerClientTestSupport.kt
- ../sessions/testSrc/CodexTestAppServer.kt
- ../sessions/testSrc/CodexSessionsToolWindowTest.kt
---
# Agent Threads Testing
Status: Draft
Date: 2026-02-08
## Scope
Validate Codex app-server thread listing and tool window UI states with a contract test suite that runs against mock and (optionally) real Codex backends.
## Contract Suite
- `CodexAppServerClientTest` is a parameterized contract test that executes against:
- Mock app-server (`CodexTestAppServer`) using a synthetic config file.
- Real `codex app-server` when the CLI is available (skipped otherwise).
- Both backends share the same invariant assertions:
- Threads are sorted by `updatedAt` descending.
- `archived` flags are consistent with the requested list.
- The mock backend additionally asserts exact thread IDs because the fixture is deterministic. The real backend uses only invariant assertions because thread data is user-specific and unstable.
[@test] ../sessions/testSrc/CodexAppServerClientTest.kt
## Tool Window UI Coverage
- Empty, loading, and error states render the expected copy.
- Active and archived sections render the right action labels.
- `New Thread` is available for closed project rows and clicking it does not invoke the project-open callback.
- Clicking `New Thread` invokes create-thread callback exactly once.
- Hovering `New Thread` does not change project-row layout metrics (height/content shift).
[@test] ../sessions/testSrc/CodexSessionsToolWindowTest.kt
## Integration Gating
- The real backend runs when the `codex` CLI is resolvable.
- `CODEX_BIN` can be used to point at a specific binary; otherwise PATH is used.
## Isolation
- The test creates a fresh `CODEX_HOME` directory in a temp location.
- A minimal `config.toml` is generated there for the real backend.
- `CODEX_HOME` is set only for the spawned `codex app-server` via per-process environment overrides.
- No global environment state is mutated.
## Running Locally
Use the sessions module as the test main module to avoid "No tests found":
```bash
./tests.cmd \
-Dintellij.build.test.patterns=com.intellij.agent.workbench.codex.sessions.CodexAppServerClientTest \
-Dintellij.build.test.main.module=intellij.agent.workbench.sessions
```
To point to a specific CLI binary:
```bash
export CODEX_BIN=/path/to/codex
```
Optional model override (defaults to `gpt-4o-mini`):
```bash
export CODEX_MODEL=gpt-4o-mini
```
Optional reasoning effort override (defaults to `low`):
```bash
export CODEX_REASONING_EFFORT=low
```
The real backend requires Codex CLI authentication to be available in the environment.

View File

@@ -0,0 +1,70 @@
---
name: Agent Threads Visibility and Paging
description: Deterministic rules for thread row visibility, More row rendering, and initial paging normalization in Agent Threads.
targets:
- ../sessions/src/CodexSessionsService.kt
- ../sessions/src/SessionTree.kt
- ../sessions/testSrc/CodexSessionsToolWindowTest.kt
- ../sessions/testSrc/CodexSessionsPagingLogicTest.kt
---
# Agent Threads Visibility and Paging
Status: Draft
Date: 2026-02-09
## Summary
Define a single state model for project thread rows so `Empty`, `More...`, and paging states are consistent across refresh, on-demand load, and explicit `More...` clicks.
## Goals
- Keep initial project rendering stable and deterministic.
- Avoid contradictory child rows (for example `No recent activity yet.` and `More...` together).
- Preserve incremental reveal by 3 rows per user click while allowing backend paging.
## Non-goals
- Changing backend page size.
- Adding new user-visible strings.
- Changing dedicated-frame chat routing behavior.
## Requirements
- A project with loaded threads must be rendered using descending `updatedAt` ordering.
- Initial reveal size is 3 rows.
- `More...` row is shown when either:
- there are hidden loaded threads beyond the current visible count, or
- a backend cursor exists and loaded thread count is at least 3.
- `More...` must not be shown when loaded thread count is 0, 1, or 2.
- Empty row (`No recent activity yet.`) and `More...` row are mutually exclusive.
- For initial refresh/on-demand load, when loaded thread count is below 3 and a cursor exists, implementation must eagerly fetch additional pages until one of the following is true:
- loaded thread count reaches 3,
- cursor is absent,
- a cursor loop/no-progress guard triggers.
- Eager merge must deduplicate by thread id and keep the newest value when ids collide.
- `More...` click behavior remains incremental: first reveal hidden loaded rows by +3, and only page backend when no hidden loaded rows remain.
[@test] ../sessions/testSrc/CodexSessionsToolWindowTest.kt
[@test] ../sessions/testSrc/CodexSessionsPagingLogicTest.kt
## User Experience
- Expanded projects with zero loaded threads show `No recent activity yet.`.
- Expanded projects with one or two loaded threads show only those threads.
- `More...` appears only when it can reveal additional context without conflicting with empty semantics.
## Data & Backend
- Initial fetch still starts with a single page request (`limit=50`).
- Eager normalization may request additional pages before committing state to the UI model.
- Cursor-loop guard must stop repeated requests for already seen cursors.
## Error Handling
- If eager normalization fetch fails, failure follows existing refresh/on-demand error handling paths.
- `More...` paging failure behavior remains unchanged: keep loaded threads/cursor and show load-more retry messaging.
## Testing / Local Run
- `./tests.cmd -Dintellij.build.test.patterns=com.intellij.agent.workbench.sessions.CodexSessionsToolWindowTest`
- `./tests.cmd -Dintellij.build.test.patterns=com.intellij.agent.workbench.sessions.CodexSessionsPagingLogicTest`
## Open Questions / Risks
- If backend repeatedly returns cursors without new unique threads, users may still end with fewer than 3 rows; the loop guard intentionally favors safety over unbounded requests.
## References
- `spec/codex-sessions.spec.md`
- `spec/codex-dedicated-frame.spec.md`

View File

@@ -0,0 +1,128 @@
---
name: Agent Threads Tool Window (Project-Scoped)
description: Requirements for the Agent Threads tool window with per-project app-server sessions and CodexMonitor parity.
targets:
- ../plugin/resources/META-INF/plugin.xml
- ../plugin-content.yaml
- ../sessions/src/*.kt
- ../sessions/resources/intellij.agent.workbench.sessions.xml
- ../sessions/resources/messages/CodexSessionsBundle.properties
- ../sessions/testSrc/*.kt
---
# Agent Threads Tool Window (Project-Scoped)
Status: Draft
Date: 2026-02-08
## Summary
Provide an Agent Threads tool window that matches CodexMonitors core behavior while aligning with IntelliJ Platform visual semantics: sessions are grouped by project, each project has its own Codex app-server session, and the list is a three-level hierarchy (project → thread → sub-agent placeholder).
Threads are rendered as single-line rows with short relative timestamps. The UI shows only active (non-archived) threads, exposes `Open` for closed projects via project-row context menu, exposes `New Thread` via project-row hover action for both open and closed projects, and exposes refresh in the tool window action menu.
Dedicated-frame routing semantics for thread and sub-agent opens are specified in `spec/codex-dedicated-frame.spec.md`.
## Rationale
- CodexMonitor scopes threads to a workspace by running a dedicated app-server per workspace. Mirroring this avoids fragile thread-to-project mapping.
- The Codex app-server `thread/list` response is global and does not include project paths, so per-project sessions are the only reliable way to group threads by project without heuristics.
## Goals
- Match CodexMonitors project-scoped session model (one app-server per project).
- Render a three-level hierarchy (project → thread → sub-agent placeholder) with concise, one-line rows and short relative time (e.g., `10m`).
- Support focusing/opening the corresponding IDE project when a thread is clicked.
- Start app-server sessions only for currently open projects to keep resource usage bounded.
- Use Jewel theme typography, colors, and tree metrics instead of hard-coded values.
## Non-goals
- Archived sessions UI or unarchive actions (keep for future work; see TODOs).
- Creating projects/workspaces or modifying Codex configuration from the IDE.
- Thread transcript view, compose, approvals, search, or filtering.
- Sub-agent data retrieval or status mapping (layout only; data is TBD).
## Requirements
- The tool window project registry must include currently open projects (via `ProjectManager.getInstance().openProjects`).
- The tool window project registry must include recent projects (via `RecentProjectsManagerBase.getRecentPaths()`).
- Each open project must have a dedicated Codex app-server process. Closed projects must not spawn a process until opened.
- Thread lists must be fetched per project via `thread/list` and must **exclude archived threads** (do not request archived pages).
- The UI must group threads by project name. Project names must come from Recent Projects metadata when available; otherwise fall back to the filesystem name.
- The tree must support three levels: project nodes contain thread nodes; thread nodes may contain sub-agent leaf nodes when data is available.
- Project row primary click action is open/focus project.
- A closed project group must expose an `Open` action in the row context menu; invoking it must open the project and start its session.
- Project rows must expose a hover `New Thread` action for both open and closed projects.
- Invoking `New Thread` must create a thread scoped to the selected project path and then open chat for that thread.
- `New Thread` chat-open routing must follow dedicated-frame mode semantics:
- dedicated-frame mode (`agent.workbench.chat.open.in.dedicated.frame=true`): keep source project closed if it is currently closed.
- current-project mode (`agent.workbench.chat.open.in.dedicated.frame=false`): open/focus source project as needed.
- Clicking `New Thread` must not trigger the project row click action.
- Project rows must always be expandable; expanding a closed project loads threads on demand without opening the IDE window.
- Project groups are expanded by default so thread rows are visible immediately.
- If a user collapses a project group, that collapse preference must be persisted and respected on subsequent renders/restarts.
- Thread row visibility, `More...` rendering, and paging/reveal sequencing must follow `spec/codex-sessions-thread-visibility.spec.md`.
- Open-project thread previews should be restored from persisted UI state for fast first paint, then refreshed in the background.
- Clicking a thread row must open the chat editor according to `agent.workbench.chat.open.in.dedicated.frame`:
- `true` (default): open in a dedicated AI-chat frame project without opening the source project.
- `false`: open in the source project frame (opening the source project first when closed).
- Clicking a sub-agent row must open a separate chat editor tab scoped to that sub-agent using the same mode.
- Archived session actions must be hidden from the UI, and the code must include a TODO noting future unarchive support.
Detailed dedicated-frame project lifecycle, visibility, and reuse requirements are defined in `spec/codex-dedicated-frame.spec.md`.
[@test] ../sessions/testSrc/CodexSessionsToolWindowTest.kt
- Integration-style coverage must include backend-backed tree scenarios (saved preview restore and `More...` paging) against both mock and real Codex backends.
- Real Codex backend scenarios are environment-gated (`CODEX_BIN`/auth available); mock backend scenarios must always run.
## User Experience
- Use the standard tool window title bar (do not duplicate the title in the content).
- Tool window action menu (gear/context menu) includes `Open...` (platform `Open File or Project`), `Open Chat in Dedicated Frame` (toggle), and `Refresh`.
- For each project, render a compact header row styled like CodexMonitors workspace row, using Jewel theme typography and tree metrics. Keep the row clean, use regular-weight text (no bold), and expose `Open` via the project context menu when the project is closed.
- Project rows expose a trailing hover `New Thread` action for both open and closed projects.
- Hover action reveal must not change project-row height or shift content horizontally.
- `New Thread` hover action uses pointer hand cursor and a stable trailing action slot.
- Project rows use a subtle neutral background tint derived from tree/list selection colors (unless selected/active) to emphasize project grouping.
- Rows use tree metrics for padding and spacing (no extra per-row padding).
- Project rows always show a chevron; there should be no empty chevron placeholders when a row has no children yet.
- Reduce tree indent locally so chevron + depth reads as a single step.
- Expanding a closed project loads threads on demand without opening the IDE window.
- Project groups are expanded by default unless the user previously collapsed that project.
- Each thread row is a single line: default status dot, title, and short relative time (e.g., `now`, `10m`, `2h`). Thread rows use theme regular text and theme small text for time.
- Each project initially shows up to 3 thread rows; detailed `More...` row visibility rules are defined in `spec/codex-sessions-thread-visibility.spec.md`.
- Clicking a thread row opens its chat editor in dedicated frame mode by default; when the dedicated-frame setting is disabled, it opens in the current project frame (opening the project first if needed).
- Sub-agent rows are a third level with a smaller status dot and theme small text (no extra weight). (Data population is TODO.)
- Clicking a sub-agent row opens a separate chat editor tab for that sub-agent.
- When a project is loading, show a lightweight busy indicator in its section.
- Empty state uses `Loading threads...` during the initial refresh.
- After refresh, if there are no project rows, show `Open a project to start activity.` with muted text styling.
- When project rows are present, do not render a global helper or empty-state line above the tree.
- When a project is expanded and has no threads, show a muted `No recent activity yet.` child row.
- Error state should be localized and provide a retry action.
## Data & Backend
- Codex app-server protocol is JSON-RPC over stdio, one JSON object per line (no `jsonrpc` field).
- Each project session must be started with `codex app-server` using the project path as `cwd`.
- Closed-project on-demand operations may use short-lived app-server clients scoped to that project path; long-lived sessions remain tied to open IDE projects.
- Do not set `CODEX_HOME`; rely on the default environment so project `.codex/` config is discovered via `cwd`.
- Ordering: threads must be sorted by `updated_at` descending per project.
- Paging must stop when there is no `nextCursor`.
- Initial per-project fetch should request a larger backend page (50) for responsiveness, while UI reveal remains incremental by 3 rows.
- Sub-agent data is not currently returned by the Codex app-server; keep model placeholders and render sub-agent rows only when data is present.
## Error Handling
- If the Codex CLI is missing or a project session fails to start, surface a project-local error state.
- If a thread list request fails, the project group should show a retry action.
- If a refresh fails for a project that already has loaded threads, keep existing thread rows and cursor state; show a non-blocking inline error with retry.
- If `More...` paging fails, keep already loaded threads and cursor state and show inline retry feedback that retries loading additional threads for the same project.
## Testing / Local Run
- Add UI tests for grouping, `Open` action availability (no inline link), and short relative time formatting.
- Add unit tests for toolwindow gear actions wiring (`Open...` + `Refresh`) and a smoke test that `Refresh` updates sessions state.
- Add service tests for per-project session lifecycle and for skipping archived thread fetches.
## Open Questions / Risks
- Thread “active” highlighting has no IDE equivalent yet; decide whether to add it later.
- Sub-agent status mapping and data source are TBD.
## References
- CodexMonitor source code is the primary behavioral reference for grouping and time formatting.
- `spec/codex-dedicated-frame.spec.md`
- `spec/codex-sessions-thread-visibility.spec.md`