mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-04-19 13:02:30 +07:00
IJPL-233558 IJ-MR-181153 initial version
GitOrigin-RevId: fec4a9abdb27dddc8feb1affbb74d6313d8369ac
This commit is contained in:
committed by
intellij-monorepo-bot
parent
3386284b3c
commit
c127ce6ec9
4
.idea/modules.xml
generated
4
.idea/modules.xml
generated
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
11
plugins/agent-workbench/AGENTS.md
Normal file
11
plugins/agent-workbench/AGENTS.md
Normal 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
|
||||
72
plugins/agent-workbench/README.md
Normal file
72
plugins/agent-workbench/README.md
Normal 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.*'
|
||||
```
|
||||
67
plugins/agent-workbench/chat/BUILD.bazel
Normal file
67
plugins/agent-workbench/chat/BUILD.bazel
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
chat.filetype.name=Agent Chat
|
||||
chat.filetype.description=Agent chat session
|
||||
17
plugins/agent-workbench/chat/src/CodexChatBundle.kt
Normal file
17
plugins/agent-workbench/chat/src/CodexChatBundle.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
34
plugins/agent-workbench/chat/src/CodexChatEditorService.kt
Normal file
34
plugins/agent-workbench/chat/src/CodexChatEditorService.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
39
plugins/agent-workbench/chat/src/CodexChatFileEditor.kt
Normal file
39
plugins/agent-workbench/chat/src/CodexChatFileEditor.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
17
plugins/agent-workbench/chat/src/CodexChatFileType.kt
Normal file
17
plugins/agent-workbench/chat/src/CodexChatFileType.kt
Normal 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
|
||||
}
|
||||
24
plugins/agent-workbench/chat/src/CodexChatVirtualFile.kt
Normal file
24
plugins/agent-workbench/chat/src/CodexChatVirtualFile.kt
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
26
plugins/agent-workbench/codex/common/BUILD.bazel
Normal file
26
plugins/agent-workbench/codex/common/BUILD.bazel
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
409
plugins/agent-workbench/codex/common/src/CodexAppServerClient.kt
Normal file
409
plugins/agent-workbench/codex/common/src/CodexAppServerClient.kt
Normal 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")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
45
plugins/agent-workbench/codex/common/src/CodexModels.kt
Normal file
45
plugins/agent-workbench/codex/common/src/CodexModels.kt
Normal 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,
|
||||
)
|
||||
7
plugins/agent-workbench/plugin-content.yaml
Normal file
7
plugins/agent-workbench/plugin-content.yaml
Normal 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
|
||||
43
plugins/agent-workbench/plugin/BUILD.bazel
Normal file
43
plugins/agent-workbench/plugin/BUILD.bazel
Normal 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
|
||||
@@ -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>
|
||||
12
plugins/agent-workbench/plugin/plugin-content.yaml
Normal file
12
plugins/agent-workbench/plugin/plugin-content.yaml
Normal 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
|
||||
12
plugins/agent-workbench/plugin/resources/META-INF/plugin.xml
Normal file
12
plugins/agent-workbench/plugin/resources/META-INF/plugin.xml
Normal 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>
|
||||
@@ -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
|
||||
79
plugins/agent-workbench/sessions/BUILD.bazel
Normal file
79
plugins/agent-workbench/sessions/BUILD.bazel
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
36
plugins/agent-workbench/sessions/src/CodexProjectPaths.kt
Normal file
36
plugins/agent-workbench/sessions/src/CodexProjectPaths.kt
Normal 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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
25
plugins/agent-workbench/sessions/src/CodexSessionsBundle.kt
Normal file
25
plugins/agent-workbench/sessions/src/CodexSessionsBundle.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
745
plugins/agent-workbench/sessions/src/CodexSessionsService.kt
Normal file
745
plugins/agent-workbench/sessions/src/CodexSessionsService.kt
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
255
plugins/agent-workbench/sessions/src/SessionTree.kt
Normal file
255
plugins/agent-workbench/sessions/src/SessionTree.kt
Normal 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
|
||||
}
|
||||
334
plugins/agent-workbench/sessions/src/SessionTreeRows.kt
Normal file
334
plugins/agent-workbench/sessions/src/SessionTreeRows.kt
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
67
plugins/agent-workbench/sessions/src/SessionTreeState.kt
Normal file
67
plugins/agent-workbench/sessions/src/SessionTreeState.kt
Normal 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
|
||||
}
|
||||
144
plugins/agent-workbench/sessions/src/SessionTreeStyle.kt
Normal file
144
plugins/agent-workbench/sessions/src/SessionTreeStyle.kt
Normal 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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
377
plugins/agent-workbench/sessions/testSrc/CodexTestAppServer.kt
Normal file
377
plugins/agent-workbench/sessions/testSrc/CodexTestAppServer.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
plugins/agent-workbench/spec-format/LICENSE
Normal file
21
plugins/agent-workbench/spec-format/LICENSE
Normal 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.
|
||||
68
plugins/agent-workbench/spec-format/SPEC_GUIDE.md
Normal file
68
plugins/agent-workbench/spec-format/SPEC_GUIDE.md
Normal 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".
|
||||
65
plugins/agent-workbench/spec/codex-chat-editor.spec.md
Normal file
65
plugins/agent-workbench/spec/codex-chat-editor.spec.md
Normal 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.
|
||||
83
plugins/agent-workbench/spec/codex-dedicated-frame.spec.md
Normal file
83
plugins/agent-workbench/spec/codex-dedicated-frame.spec.md
Normal 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`
|
||||
73
plugins/agent-workbench/spec/codex-sessions-testing.spec.md
Normal file
73
plugins/agent-workbench/spec/codex-sessions-testing.spec.md
Normal 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.
|
||||
@@ -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`
|
||||
128
plugins/agent-workbench/spec/codex-sessions.spec.md
Normal file
128
plugins/agent-workbench/spec/codex-sessions.spec.md
Normal 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 CodexMonitor’s 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 CodexMonitor’s 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 CodexMonitor’s 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`
|
||||
Reference in New Issue
Block a user