IJPL-248623 add task folder agent workflow

Add the Pi-only task-folder agent workflow so users can start an agent-backed folder from the tree popup or from the folder creation dialog while the generic MCP surface remains out of scope.

The implementation uses the existing authenticated Pi extension control socket to expose a create-and-assign task-folder tool to the running Pi session. Launch profile selection is scoped to Pi and remembered separately from the general new-thread preference.

GitOrigin-RevId: 178d047a365462c4cc142c3f0a700f05a1e2e7fe
This commit is contained in:
Vladimir Krivosheev
2026-07-09 18:59:26 +00:00
committed by intellij-monorepo-bot
parent ae1894fc5f
commit e94aefbf55
16 changed files with 969 additions and 58 deletions
@@ -1,5 +1,5 @@
import {type ExtensionAPI, type ExtensionContext} from "@earendil-works/pi-coding-agent";
import {startControlBridge} from "./control.ts";
import {registerTaskFolderTool, startControlBridge} from "./control.ts";
import {registerJbCentralProvider} from "./jbcentral.ts";
import {parseModelCatalogMetadata} from "./modelCatalog.ts";
import {registerOmlxProviders} from "./omlx.ts";
@@ -33,6 +33,8 @@ export default async function agentWorkbenchTheme(pi: ExtensionAPI) {
let lastSessionInfoSignature: string | undefined;
let lastSessionInfoLeafId: string | null | undefined;
registerTaskFolderTool(pi, () => controlBridge);
const updateLastStatusSignature = (signature: string) => {
lastStatusSignature = signature;
};
@@ -1,4 +1,5 @@
import {type ExtensionContext} from "@earendil-works/pi-coding-agent";
import {Type} from "@earendil-works/pi-ai";
import {defineTool, type ExtensionAPI, type ExtensionContext} from "@earendil-works/pi-coding-agent";
import * as crypto from "node:crypto";
import * as http from "node:http";
import {type Duplex} from "node:stream";
@@ -14,6 +15,7 @@ type AgentWorkbenchControlBridge = {
setContext: (ctx: ExtensionContext) => void;
getCurrentTaskFolder: () => Promise<AgentWorkbenchTaskFolder | undefined>;
listTaskFolderThreads: (folderId?: string) => Promise<AgentWorkbenchTaskFolderThread[]>;
createAndAssignTaskFolder: (name: string, metadata?: Record<string, string>) => Promise<AgentWorkbenchCreatedTaskFolder>;
getTaskFolderMetadata: (folderId?: string) => Promise<Record<string, string> | undefined>;
setTaskFolderMetadata: (key: string, value: string, folderId?: string) => Promise<boolean>;
deleteTaskFolderMetadata: (key: string, folderId?: string) => Promise<boolean>;
@@ -28,6 +30,7 @@ type AgentWorkbenchControlMessageType =
| "forkFromEntry"
| "getCurrentTaskFolder"
| "listTaskFolderThreads"
| "createAndAssignTaskFolder"
| "getTaskFolderMetadata"
| "setTaskFolderMetadata"
| "deleteTaskFolderMetadata";
@@ -36,6 +39,7 @@ type AgentWorkbenchControlSessionMessageType = "hello" | "sessionState";
type AgentWorkbenchControlRequestType =
| "getCurrentTaskFolder"
| "listTaskFolderThreads"
| "createAndAssignTaskFolder"
| "getTaskFolderMetadata"
| "setTaskFolderMetadata"
| "deleteTaskFolderMetadata";
@@ -51,12 +55,14 @@ type AgentWorkbenchControlCommand = {
cancelled?: boolean;
error?: string;
folderId?: string;
name?: string;
key?: string;
value?: string;
changed?: boolean;
folder?: AgentWorkbenchTaskFolder | null;
threads?: AgentWorkbenchTaskFolderThread[];
metadata?: Record<string, string>;
assigned?: boolean;
};
type AgentWorkbenchControlThread = {
@@ -84,6 +90,11 @@ type AgentWorkbenchTaskFolderThread = {
assignedAt?: number;
};
type AgentWorkbenchCreatedTaskFolder = {
folder: AgentWorkbenchTaskFolder;
assigned: boolean;
};
type AgentWorkbenchControlContext = ExtensionContext & {
navigateTree?: (entryId: string) => Promise<void> | void;
fork?: (
@@ -293,6 +304,13 @@ export function startControlBridge(ctx: ExtensionContext): AgentWorkbenchControl
const response = requireOk(await sendRequest("listTaskFolderThreads", {folderId}));
return response.threads ?? [];
},
createAndAssignTaskFolder: async (name, metadata) => {
const response = requireOk(await sendRequest("createAndAssignTaskFolder", {name, metadata}));
if (response.folder === undefined || response.folder === null) {
throw new Error("Agent Workbench did not return the created task folder");
}
return {folder: response.folder, assigned: response.assigned === true};
},
getTaskFolderMetadata: async (folderId) => {
const response = requireOk(await sendRequest("getTaskFolderMetadata", {folderId}));
return response.metadata;
@@ -316,6 +334,39 @@ export function startControlBridge(ctx: ExtensionContext): AgentWorkbenchControl
};
}
export function registerTaskFolderTool(
pi: ExtensionAPI,
bridgeProvider: () => AgentWorkbenchControlBridge | undefined,
): void {
pi.registerTool(defineTool({
name: "agent_workbench_create_task_folder",
label: "Create Task Folder",
description: "Create an Agent Workbench task folder for the current project and assign the current Pi session to it.",
promptSnippet: "Create an Agent Workbench task folder and assign this Pi thread to it",
promptGuidelines: [
"Use agent_workbench_create_task_folder when the user asks to create a task folder or start work in a task folder.",
"Pass the issue id as issue when the user mentions an issue tracker id.",
],
parameters: Type.Object({
name: Type.String({description: "Task folder name"}),
issue: Type.Optional(Type.String({description: "Issue tracker id to store as task folder metadata key 'issue'"})),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const bridge = bridgeProvider();
if (bridge === undefined) {
throw new Error("Agent Workbench control bridge is unavailable");
}
const issue = params.issue?.trim();
const metadata = issue === undefined || issue.length === 0 ? undefined : {issue};
const result = await bridge.createAndAssignTaskFolder(params.name, metadata);
return {
content: [{type: "text", text: `Created task folder '${result.folder.name}' and assigned this thread.`}],
details: result,
};
},
}));
}
function resolveCapabilities(ctx: ExtensionContext): { navigateTree: boolean; fork: boolean } {
const controlCtx = ctx as AgentWorkbenchControlContext;
return {
@@ -89,6 +89,7 @@ internal object PiExtensionControlBridge {
PiControlMessageType.RESPONSE -> handleResponse(webSocketClient, payload)
PiControlMessageType.GET_CURRENT_TASK_FOLDER,
PiControlMessageType.LIST_TASK_FOLDER_THREADS,
PiControlMessageType.CREATE_AND_ASSIGN_TASK_FOLDER,
PiControlMessageType.GET_TASK_FOLDER_METADATA,
PiControlMessageType.SET_TASK_FOLDER_METADATA,
PiControlMessageType.DELETE_TASK_FOLDER_METADATA,
@@ -21,6 +21,7 @@ internal enum class PiControlMessageType(@JvmField val wireName: String) {
FORK_FROM_ENTRY("forkFromEntry"),
GET_CURRENT_TASK_FOLDER("getCurrentTaskFolder"),
LIST_TASK_FOLDER_THREADS("listTaskFolderThreads"),
CREATE_AND_ASSIGN_TASK_FOLDER("createAndAssignTaskFolder"),
GET_TASK_FOLDER_METADATA("getTaskFolderMetadata"),
SET_TASK_FOLDER_METADATA("setTaskFolderMetadata"),
DELETE_TASK_FOLDER_METADATA("deleteTaskFolderMetadata");
@@ -69,8 +70,10 @@ internal data class PiControlPayload(
@JvmField val thread: PiControlThreadPayload? = null,
@JvmField val capabilities: PiControlCapabilities? = null,
@JvmField val folderId: String? = null,
@JvmField val name: String? = null,
@JvmField val key: String? = null,
@JvmField val value: String? = null,
@JvmField val metadata: Map<String, String>? = null,
)
internal data class PiControlThreadPayload(
@@ -165,8 +168,10 @@ private fun readControlPayload(parser: JsonParser): PiControlPayload {
var thread: PiControlThreadPayload? = null
var capabilities: PiControlCapabilities? = null
var folderId: String? = null
var name: String? = null
var key: String? = null
var value: String? = null
var metadata: Map<String, String>? = null
forEachJsonObjectField(parser) { fieldName ->
when (fieldName) {
"type" -> type = PiControlMessageType.fromWireName(readJsonStringOrNull(parser))
@@ -180,8 +185,10 @@ private fun readControlPayload(parser: JsonParser): PiControlPayload {
"thread" -> thread = readControlThreadPayload(parser)
"capabilities" -> capabilities = readControlCapabilities(parser)
"folderId" -> folderId = readJsonStringOrNull(parser)
"name" -> name = readJsonStringOrNull(parser)
"key" -> key = readJsonStringOrNull(parser)
"value" -> value = readJsonStringOrNull(parser)
"metadata" -> metadata = readControlStringMap(parser)
else -> parser.skipChildren()
}
true
@@ -198,8 +205,10 @@ private fun readControlPayload(parser: JsonParser): PiControlPayload {
thread = thread,
capabilities = capabilities,
folderId = folderId,
name = name,
key = key,
value = value,
metadata = metadata,
)
}
@@ -243,6 +252,22 @@ private fun readControlCapabilities(parser: JsonParser): PiControlCapabilities?
return PiControlCapabilities(navigateTree = navigateTree, fork = fork)
}
private fun readControlStringMap(parser: JsonParser): Map<String, String>? {
if (parser.currentToken() != JsonToken.START_OBJECT) {
parser.skipChildren()
return null
}
val result = LinkedHashMap<String, String>()
forEachJsonObjectField(parser) { fieldName ->
when (parser.currentToken()) {
JsonToken.VALUE_STRING -> readJsonStringOrNull(parser)?.let { value -> result[fieldName] = value }
else -> parser.skipChildren()
}
true
}
return result
}
@Suppress("DuplicatedCode")
private fun readJsonBooleanOrNull(parser: JsonParser): Boolean? {
return when (parser.currentToken()) {
@@ -26,6 +26,22 @@ internal class PiTaskFolderControlHandler(
buildTaskFolderAssignmentsResponse(requestId, service.listFolderThreadAssignments(folderId))
}
}
PiControlMessageType.CREATE_AND_ASSIGN_TASK_FOLDER -> {
val name = payload.name?.trim()?.takeIf { it.isNotEmpty() }
if (name == null) {
buildPiControlErrorResponse(requestId, "Task folder name is required")
}
else {
val folder = service.createFolder(context.projectPath, name, payload.metadata.orEmpty())
if (folder == null) {
buildPiControlErrorResponse(requestId, "Task folder could not be created")
}
else {
val assigned = service.assignThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId, folder.id)
buildTaskFolderCreatedResponse(requestId = requestId, folder = folder, assigned = assigned)
}
}
}
PiControlMessageType.GET_TASK_FOLDER_METADATA -> {
val folder = resolveTaskFolder(service, context, payload)
if (folder == null) {
@@ -63,6 +79,17 @@ internal class PiTaskFolderControlHandler(
}
}
private fun buildTaskFolderCreatedResponse(requestId: String, folder: AgentTaskFolder, assigned: Boolean): String {
return buildPiControlJsonObject { generator ->
generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
generator.writeStringProperty("requestId", requestId)
generator.writeBooleanProperty("ok", true)
generator.writeName("folder")
writeTaskFolder(generator, folder)
generator.writeBooleanProperty("assigned", assigned)
}
}
private fun buildTaskFolderResponse(requestId: String, folder: AgentTaskFolder?): String {
return buildPiControlJsonObject { generator ->
generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
@@ -241,6 +241,24 @@ class PiExtensionControlWebSocketHandlerTest {
val deleteResponse = listener.nextMessage()
assertThat(deleteResponse).contains("\"changed\":true")
assertThat(folderService.getFolder(folder.id)?.metadata).doesNotContainKey("review")
webSocket.sendText(
controlTaskFolderRequest(
requestId = "request-create",
type = "createAndAssignTaskFolder",
name = "Follow-up task",
metadata = mapOf("issue" to "IJPL-999999"),
),
true,
).join()
val createResponse = listener.nextMessage()
val createdFolder = requireNotNull(folderService.getFolderForThread(projectDir.toString(), PI_AGENT_SESSION_PROVIDER, sessionId))
assertThat(createResponse).contains("\"requestId\":\"request-create\"")
assertThat(createResponse).contains("\"ok\":true")
assertThat(createResponse).contains("\"assigned\":true")
assertThat(createResponse).contains("\"id\":${createdFolder.id.jsonString()}")
assertThat(createdFolder.name).isEqualTo("Follow-up task")
assertThat(createdFolder.metadata).containsEntry("issue", "IJPL-999999")
}
finally {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join()
@@ -424,16 +442,25 @@ private fun controlTaskFolderRequest(
requestId: String,
type: String,
folderId: String? = null,
name: String? = null,
key: String? = null,
value: String? = null,
metadata: Map<String, String>? = null,
): String {
val fields = mutableListOf(
"\"type\":${type.jsonString()}",
"\"requestId\":${requestId.jsonString()}",
)
folderId?.let { fields += "\"folderId\":${it.jsonString()}" }
name?.let { fields += "\"name\":${it.jsonString()}" }
key?.let { fields += "\"key\":${it.jsonString()}" }
value?.let { fields += "\"value\":${it.jsonString()}" }
metadata?.let { values ->
val metadataFields = values.entries.joinToString(",") { (metadataKey, metadataValue) ->
"${metadataKey.jsonString()}:${metadataValue.jsonString()}"
}
fields += "\"metadata\":{$metadataFields}"
}
return "{${fields.joinToString(",")}}"
}
@@ -49,6 +49,11 @@
id="AgentWorkbenchSessions.TreePopup.CreateTaskFolder"
icon="AllIcons.General.Add"
class="com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupCreateTaskFolderAction"/>
<group
id="AgentWorkbenchSessions.TreePopup.TaskFolderAgent"
popup="true"
icon="AllIcons.General.Add"
class="com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupTaskFolderAgentGroup"/>
<group
id="AgentWorkbenchSessions.TreePopup.MoveToTaskFolder"
popup="true"
@@ -91,6 +96,7 @@
<reference ref="AgentWorkbenchSessions.TreePopup.More"/>
<reference ref="AgentWorkbenchSessions.TreePopup.NewThread"/>
<reference ref="AgentWorkbenchSessions.TreePopup.CreateTaskFolder"/>
<reference ref="AgentWorkbenchSessions.TreePopup.TaskFolderAgent"/>
<separator/>
<reference ref="AgentWorkbenchSessions.TreePopup.MoveToTaskFolder"/>
<reference ref="AgentWorkbenchSessions.TreePopup.RemoveFromTaskFolder"/>
@@ -6,6 +6,7 @@ import com.intellij.agent.workbench.sessions.model.ArchiveThreadTarget
import com.intellij.agent.workbench.sessions.service.AgentSessionArchiveRequestResult
import com.intellij.agent.workbench.sessions.service.AgentSessionArchiveService
import com.intellij.agent.workbench.sessions.statistics.AgentWorkbenchEntryPoint
import com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
@@ -14,35 +15,59 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.InputValidatorEx
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.openapi.util.NlsContexts
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.ai.agent.core.session.AgentSessionProvider
import com.intellij.platform.ai.agent.sessions.core.SessionActionTarget
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolder
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderService
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderStatus
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
import com.intellij.util.ui.JBUI
import java.awt.event.ActionEvent
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.DefaultComboBoxModel
import javax.swing.Action
import javax.swing.JComponent
import javax.swing.JPanel
internal class AgentSessionsTreePopupCreateTaskFolderAction : DumbAwareAction {
private val resolveContext: (AnActionEvent) -> AgentSessionsTreePopupActionContext?
private val promptForName: (Project) -> String?
private val createFolder: (String, String) -> Unit
private val promptForCreateRequest: (Project, Boolean) -> CreateTaskFolderRequest?
private val createFolder: (String, String) -> AgentTaskFolder?
private val taskFolderAgentProfile: (Project) -> AgentPromptLaunchProfile?
private val openTaskFolderAgent: (String, AgentTaskFolder, AgentPromptLaunchProfile, Project) -> Unit
@Suppress("unused")
constructor() {
resolveContext = ::resolveAgentSessionsTreePopupActionContext
promptForName = ::showCreateTaskFolderDialog
promptForCreateRequest = ::showCreateTaskFolderDialog
createFolder = { path, name -> service<AgentTaskFolderService>().createFolder(path, name) }
taskFolderAgentProfile = ::resolveTaskFolderAgentQuickStartProfile
openTaskFolderAgent = { path, folder, profile, project ->
createTaskFolderAgentViaService(path, profile, project, AgentWorkbenchEntryPoint.TREE_POPUP, folder)
}
}
internal constructor(
resolveContext: (AnActionEvent) -> AgentSessionsTreePopupActionContext?,
promptForName: (Project) -> String?,
createFolder: (String, String) -> Unit,
promptForCreateRequest: (Project, Boolean) -> CreateTaskFolderRequest?,
createFolder: (String, String) -> AgentTaskFolder?,
taskFolderAgentProfile: (Project) -> AgentPromptLaunchProfile? = { null },
openTaskFolderAgent: (String, AgentTaskFolder, AgentPromptLaunchProfile, Project) -> Unit = { _, _, _, _ -> },
) {
this.resolveContext = resolveContext
this.promptForName = promptForName
this.promptForCreateRequest = promptForCreateRequest
this.createFolder = createFolder
this.taskFolderAgentProfile = taskFolderAgentProfile
this.openTaskFolderAgent = openTaskFolderAgent
}
override fun update(e: AnActionEvent) {
@@ -52,8 +77,13 @@ internal class AgentSessionsTreePopupCreateTaskFolderAction : DumbAwareAction {
override fun actionPerformed(e: AnActionEvent) {
val context = resolveContext(e) ?: return
val path = createFolderPath(context.target) ?: return
val name = promptForName(context.project) ?: return
createFolder(path, name)
val profile = taskFolderAgentProfile(context.project)
val request = promptForCreateRequest(context.project, profile != null) ?: return
val folder = createFolder(path, request.name) ?: return
if (request.createWithAgent) {
val effectiveProfile = profile ?: taskFolderAgentProfile(context.project) ?: return
openTaskFolderAgent(path, folder, effectiveProfile, context.project)
}
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
@@ -173,13 +203,26 @@ internal class AgentSessionsTreePopupRemoveFromTaskFolderAction : DumbAwareActio
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}
internal class AgentSessionsTreePopupSetTaskFolderMetadataAction : DumbAwareAction() {
private val resolveContext: (AnActionEvent) -> AgentSessionsTreePopupActionContext? =
::resolveAgentSessionsTreePopupActionContext
private val promptForMetadata: (Project, SessionActionTarget.TaskFolder) -> Pair<String, String>? =
::showSetTaskFolderMetadataDialog
private val setMetadata: (SessionActionTarget.TaskFolder, String, String) -> Unit = { target, key, value ->
service<AgentTaskFolderService>().setMetadata(target.folderId, key, value)
internal class AgentSessionsTreePopupSetTaskFolderMetadataAction : DumbAwareAction {
private val resolveContext: (AnActionEvent) -> AgentSessionsTreePopupActionContext?
private val promptForMetadata: (Project, SessionActionTarget.TaskFolder) -> TaskFolderMetadataEdit?
private val setMetadata: (SessionActionTarget.TaskFolder, String, String) -> Unit
@Suppress("unused")
constructor() {
resolveContext = ::resolveAgentSessionsTreePopupActionContext
promptForMetadata = ::showSetTaskFolderMetadataDialog
setMetadata = { target, key, value -> service<AgentTaskFolderService>().setMetadata(target.folderId, key, value) }
}
internal constructor(
resolveContext: (AnActionEvent) -> AgentSessionsTreePopupActionContext?,
promptForMetadata: (Project, SessionActionTarget.TaskFolder) -> TaskFolderMetadataEdit?,
setMetadata: (SessionActionTarget.TaskFolder, String, String) -> Unit,
) {
this.resolveContext = resolveContext
this.promptForMetadata = promptForMetadata
this.setMetadata = setMetadata
}
override fun update(e: AnActionEvent) {
@@ -189,13 +232,35 @@ internal class AgentSessionsTreePopupSetTaskFolderMetadataAction : DumbAwareActi
override fun actionPerformed(e: AnActionEvent) {
val context = resolveContext(e) ?: return
val target = context.target as? SessionActionTarget.TaskFolder ?: return
val (key, value) = promptForMetadata(context.project, target) ?: return
setMetadata(target, key, value)
val metadata = promptForMetadata(context.project, target)?.let(::resolveTaskFolderMetadataUpdate) ?: return
setMetadata(target, metadata.key, metadata.value)
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}
private val TASK_FOLDER_METADATA_KEY_PRESETS: List<@NlsSafe String> = listOf("issue", "review")
internal data class TaskFolderMetadataEdit(
@JvmField val key: String,
@JvmField val value: String,
)
internal data class CreateTaskFolderRequest(
@JvmField val name: String,
@JvmField val createWithAgent: Boolean,
)
internal data class TaskFolderMetadataUpdate(
@JvmField val key: String,
@JvmField val value: String,
)
internal fun resolveTaskFolderMetadataUpdate(edit: TaskFolderMetadataEdit): TaskFolderMetadataUpdate? {
val key = edit.key.trim().takeIf { it.isNotEmpty() } ?: return null
return TaskFolderMetadataUpdate(key = key, value = edit.value)
}
internal class AgentSessionsTreePopupDeleteTaskFolderMetadataAction : DumbAwareAction() {
private val resolveContext: (AnActionEvent) -> AgentSessionsTreePopupActionContext? =
::resolveAgentSessionsTreePopupActionContext
@@ -337,13 +402,10 @@ private fun createFolderPath(target: SessionActionTarget?): String? {
}
}
private fun showCreateTaskFolderDialog(project: Project): String? {
return showTaskFolderNameDialog(
project = project,
title = AgentSessionsBundle.message("toolwindow.task.folder.create.dialog.title"),
message = AgentSessionsBundle.message("toolwindow.task.folder.create.dialog.message"),
initialValue = "",
)
private fun showCreateTaskFolderDialog(project: Project, canCreateWithAgent: Boolean): CreateTaskFolderRequest? {
val dialog = CreateTaskFolderDialog(project, canCreateWithAgent)
if (!dialog.showAndGet()) return null
return dialog.createRequest()
}
private fun showRenameTaskFolderDialog(project: Project, currentName: String): String? {
@@ -373,24 +435,9 @@ private fun confirmDeleteTaskFolder(project: Project, target: SessionActionTarge
) == Messages.YES
}
private fun showSetTaskFolderMetadataDialog(project: Project, target: SessionActionTarget.TaskFolder): Pair<String, String>? {
val key = Messages.showInputDialog(
project,
AgentSessionsBundle.message("toolwindow.task.folder.metadata.key.dialog.message"),
AgentSessionsBundle.message("toolwindow.task.folder.metadata.set.dialog.title"),
Messages.getQuestionIcon(),
target.metadata.keys.firstOrNull().orEmpty(),
NonBlankInputValidator(),
) ?: return null
val value = Messages.showInputDialog(
project,
AgentSessionsBundle.message("toolwindow.task.folder.metadata.value.dialog.message"),
AgentSessionsBundle.message("toolwindow.task.folder.metadata.set.dialog.title"),
Messages.getQuestionIcon(),
target.metadata[key].orEmpty(),
null,
) ?: return null
return key to value
private fun showSetTaskFolderMetadataDialog(project: Project, target: SessionActionTarget.TaskFolder): TaskFolderMetadataEdit? {
val dialog = TaskFolderMetadataDialog(project, target)
return if (dialog.showAndGet()) dialog.metadataEdit() else null
}
private fun showDeleteTaskFolderMetadataDialog(project: Project, target: SessionActionTarget.TaskFolder): String? {
@@ -413,3 +460,145 @@ private class NonBlankInputValidator : InputValidatorEx {
return if (checkInput(inputString)) null else AgentSessionsBundle.message("toolwindow.task.folder.dialog.validation.empty")
}
}
private class CreateTaskFolderDialog(
project: Project,
private val canCreateWithAgent: Boolean,
) : DialogWrapper(project) {
private val nameField = JBTextField(32)
private var createWithAgent = false
init {
title = AgentSessionsBundle.message("toolwindow.task.folder.create.dialog.title")
setOKButtonText(AgentSessionsBundle.message("toolwindow.task.folder.create.dialog.create"))
init()
initValidation()
}
override fun createCenterPanel(): JComponent {
return JPanel(GridBagLayout()).apply {
border = JBUI.Borders.empty(8)
addMetadataRow(0, AgentSessionsBundle.message("toolwindow.task.folder.create.dialog.message"), nameField)
}
}
override fun createActions(): Array<Action> {
val createWithAgentAction = object : DialogWrapperAction(AgentSessionsBundle.message("toolwindow.task.folder.create.dialog.with.agent")) {
init {
isEnabled = canCreateWithAgent
}
override fun doAction(e: ActionEvent?) {
createWithAgent = true
doOKAction()
if (!isOK) {
createWithAgent = false
}
}
}
return arrayOf(okAction, createWithAgentAction, cancelAction)
}
override fun getPreferredFocusedComponent(): JComponent = nameField
override fun doValidate(): ValidationInfo? {
if (nameField.text.isBlank()) {
return ValidationInfo(AgentSessionsBundle.message("toolwindow.task.folder.dialog.validation.empty"), nameField)
}
return null
}
fun createRequest(): CreateTaskFolderRequest {
return CreateTaskFolderRequest(name = nameField.text, createWithAgent = createWithAgent)
}
}
private class TaskFolderMetadataDialog(
project: Project,
private val target: SessionActionTarget.TaskFolder,
) : DialogWrapper(project) {
private val keyCombo = ComboBox<@NlsSafe String>().apply {
model = DefaultComboBoxModel(taskFolderMetadataKeyOptions(target.metadata))
isEditable = true
}
private val valueField = JBTextField(32)
private var loadedKey: String? = null
init {
title = AgentSessionsBundle.message("toolwindow.task.folder.metadata.set.dialog.title")
val initialKey = initialMetadataKey(target.metadata)
keyCombo.selectedItem = initialKey
loadedKey = initialKey
valueField.text = target.metadata[initialKey].orEmpty()
keyCombo.addActionListener { handleKeyChanged() }
init()
initValidation()
}
override fun createCenterPanel(): JComponent {
return JPanel(GridBagLayout()).apply {
border = JBUI.Borders.empty(8)
addMetadataRow(0, AgentSessionsBundle.message("toolwindow.task.folder.metadata.key.dialog.message"), keyCombo)
addMetadataRow(1, AgentSessionsBundle.message("toolwindow.task.folder.metadata.value.dialog.message"), valueField)
}
}
override fun getPreferredFocusedComponent(): JComponent = keyCombo
override fun doValidate(): ValidationInfo? {
if (metadataKey().isBlank()) {
return ValidationInfo(AgentSessionsBundle.message("toolwindow.task.folder.metadata.key.validation.empty"), keyCombo)
}
return null
}
fun metadataEdit(): TaskFolderMetadataEdit {
return TaskFolderMetadataEdit(
key = metadataKey(),
value = valueField.text,
)
}
private fun handleKeyChanged() {
val key = metadataKey()
if (key == loadedKey) return
target.metadata[key]?.let { value ->
valueField.text = value
}
loadedKey = key
}
private fun metadataKey(): @NlsSafe String {
return keyCombo.editor.item?.toString().orEmpty()
}
}
private fun JPanel.addMetadataRow(row: Int, labelText: @NlsContexts.Label String, component: JComponent) {
val label = JBLabel(labelText).apply { setLabelFor(component) }
add(label, GridBagConstraints().apply {
gridx = 0
gridy = row
anchor = GridBagConstraints.WEST
insets = JBUI.insets(0, 0, 8, 8)
})
add(component, GridBagConstraints().apply {
gridx = 1
gridy = row
fill = GridBagConstraints.HORIZONTAL
weightx = 1.0
insets = JBUI.insetsBottom(8)
})
}
private fun taskFolderMetadataKeyOptions(metadata: Map<String, String>): Array<@NlsSafe String> {
val keys = LinkedHashSet<@NlsSafe String>()
keys.addAll(TASK_FOLDER_METADATA_KEY_PRESETS)
metadata.keys.filterTo(keys) { key -> key.isNotBlank() }
return keys.toTypedArray()
}
private fun initialMetadataKey(metadata: Map<String, String>): @NlsSafe String {
return TASK_FOLDER_METADATA_KEY_PRESETS.firstOrNull(metadata::containsKey)
?: metadata.keys.firstOrNull { key -> key.isNotBlank() }
?: TASK_FOLDER_METADATA_KEY_PRESETS.first()
}
@@ -0,0 +1,219 @@
// 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.toolwindow.actions
import com.intellij.agent.workbench.prompt.core.AgentPromptInitialMessageRequest
import com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile
import com.intellij.agent.workbench.sessions.AgentSessionLaunchProfileSelection
import com.intellij.agent.workbench.sessions.AgentSessionsBundle
import com.intellij.agent.workbench.sessions.buildAgentSessionLaunchProfileMenuActions
import com.intellij.agent.workbench.sessions.buildAgentSessionLaunchProfileMenuModel
import com.intellij.agent.workbench.sessions.launchQuickStartProfile
import com.intellij.agent.workbench.sessions.providerItemIconWithMode
import com.intellij.agent.workbench.sessions.resolveAgentSessionLaunchProfileSelection
import com.intellij.agent.workbench.sessions.service.AgentPreparedNewSessionLaunchContext
import com.intellij.agent.workbench.sessions.service.AgentSessionLaunchService
import com.intellij.agent.workbench.sessions.state.AgentSessionUiPreferencesStateService
import com.intellij.agent.workbench.sessions.statistics.AgentWorkbenchEntryPoint
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.platform.ai.agent.core.session.AgentSessionLaunchMode
import com.intellij.platform.ai.agent.core.session.AgentSessionProvider
import com.intellij.platform.ai.agent.sessions.core.SessionActionTarget
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolder
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderService
import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor
import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviders
import com.intellij.platform.ai.agent.sessions.core.providers.builtInLaunchProfileId
import com.intellij.platform.ai.agent.sessions.core.providers.hasEntries
internal class AgentSessionsTreePopupTaskFolderAgentGroup @JvmOverloads constructor(
private val resolveContext: (AnActionEvent) -> AgentSessionsTreePopupActionContext? =
::resolveAgentSessionsTreePopupActionContext,
private val allBridges: () -> List<AgentSessionProviderDescriptor> = AgentSessionProviders::allProviders,
private val userLaunchProfiles: () -> List<AgentPromptLaunchProfile> = { service<AgentSessionUiPreferencesStateService>().getUserLaunchProfiles() },
private val taskFolderAgentLaunchProfileId: () -> String? = {
service<AgentSessionUiPreferencesStateService>().getTaskFolderAgentLaunchProfileId()
},
private val setTaskFolderAgentLaunchProfileId: (String?) -> Unit = { profileId ->
service<AgentSessionUiPreferencesStateService>().setTaskFolderAgentLaunchProfileId(profileId)
},
private val createTaskFolderAgent: (String, AgentPromptLaunchProfile, Project, AgentWorkbenchEntryPoint) -> Unit =
{ path, profile, project, entryPoint -> createTaskFolderAgentViaService(path, profile, project, entryPoint, folder = null) },
) : ActionGroup(), DumbAware {
override fun update(e: AnActionEvent) {
val menu = resolveTaskFolderAgentMenu(e)
if (menu == null) {
e.presentation.isEnabledAndVisible = false
return
}
val quickStartItem = menu.selection.quickStartItem
val enabled = quickStartItem?.menuItem?.isEnabled == true
e.presentation.isVisible = true
e.presentation.isEnabled = enabled
e.presentation.isPopupGroup = true
e.presentation.isPerformGroup = enabled
e.presentation.icon = quickStartItem?.let { providerItemIconWithMode(it.menuItem) }
if (!enabled) {
e.presentation.description = AgentSessionsBundle.message("action.AgentWorkbenchSessions.TreePopup.TaskFolderAgent.disabled.description")
}
}
override fun actionPerformed(e: AnActionEvent) {
val menu = resolveTaskFolderAgentMenu(e) ?: return
launchQuickStartProfile(
path = menu.path,
project = menu.context.project,
quickStartItem = menu.selection.quickStartItem,
entryPoint = AgentWorkbenchEntryPoint.TREE_POPUP,
createNewSession = ::launchTaskFolderAgent,
)
}
override fun getChildren(e: AnActionEvent?): Array<AnAction> {
val menu = e?.let(::resolveTaskFolderAgentMenu) ?: return emptyArray()
if (menu.selection.profiles.isEmpty()) return emptyArray()
return buildAgentSessionLaunchProfileMenuActions(
path = menu.path,
project = menu.context.project,
selection = menu.selection,
entryPoint = AgentWorkbenchEntryPoint.TREE_POPUP,
createNewSession = ::launchTaskFolderAgent,
includeManageAction = false,
)
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
private fun resolveTaskFolderAgentMenu(e: AnActionEvent): TaskFolderAgentMenu? {
val context = resolveContext(e) ?: return null
val path = taskFolderAgentPathFromTarget(context.target) ?: return null
val selection = resolveTaskFolderAgentLaunchProfileSelection(
project = context.project,
allBridges = allBridges(),
userLaunchProfiles = userLaunchProfiles(),
preferredProfileId = taskFolderAgentLaunchProfileId(),
)
return TaskFolderAgentMenu(context = context, path = path, selection = selection)
}
private fun launchTaskFolderAgent(
path: String,
profile: AgentPromptLaunchProfile,
project: Project,
entryPoint: AgentWorkbenchEntryPoint,
) {
setTaskFolderAgentLaunchProfileId(profile.id)
createTaskFolderAgent(path, profile, project, entryPoint)
}
private data class TaskFolderAgentMenu(
val context: AgentSessionsTreePopupActionContext,
val path: String,
val selection: AgentSessionLaunchProfileSelection,
)
}
internal fun resolveTaskFolderAgentQuickStartProfile(project: Project): AgentPromptLaunchProfile? {
val selection = resolveTaskFolderAgentLaunchProfileSelection(
project = project,
allBridges = AgentSessionProviders.allProviders(),
userLaunchProfiles = service<AgentSessionUiPreferencesStateService>().getUserLaunchProfiles(),
preferredProfileId = service<AgentSessionUiPreferencesStateService>().getTaskFolderAgentLaunchProfileId(),
)
return selection.quickStartItem
?.takeIf { item -> item.menuItem.isEnabled }
?.profile
}
internal fun createTaskFolderAgentViaService(
path: String,
profile: AgentPromptLaunchProfile,
project: Project,
entryPoint: AgentWorkbenchEntryPoint,
folder: AgentTaskFolder?,
) {
service<AgentSessionUiPreferencesStateService>().setTaskFolderAgentLaunchProfileId(profile.id)
service<AgentSessionLaunchService>().createNewSession(
path = path,
launchProfileId = profile.id,
entryPoint = entryPoint,
currentProject = project,
initialMessageRequestBuilder = { context ->
AgentPromptInitialMessageRequest(prompt = buildTaskFolderAgentPrompt(path = path, folder = folder, context = context))
},
preparedLaunchHandler = folder?.let { taskFolder ->
{ context ->
service<AgentTaskFolderService>().assignThread(path, context.provider, context.threadId, taskFolder.id)
}
},
singleFlightDiscriminator = "task-folder-agent:${folder?.id ?: "new"}",
updateGeneralProviderPreferences = false,
threadTitle = folder?.name,
)
}
private fun resolveTaskFolderAgentLaunchProfileSelection(
project: Project,
allBridges: List<AgentSessionProviderDescriptor>,
userLaunchProfiles: List<AgentPromptLaunchProfile>,
preferredProfileId: String?,
): AgentSessionLaunchProfileSelection {
val piBridges = allBridges.filter { descriptor -> descriptor.provider == TASK_FOLDER_AGENT_PROVIDER }
val menuModel = buildAgentSessionLaunchProfileMenuModel(piBridges, project)
if (!menuModel.hasEntries()) {
return AgentSessionLaunchProfileSelection(profiles = emptyList(), quickStartItem = null)
}
return resolveAgentSessionLaunchProfileSelection(
menuModel = menuModel,
userProfiles = userLaunchProfiles.filter { profile -> profile.providerId == TASK_FOLDER_AGENT_PROVIDER.value },
preferredProfileId = preferredProfileId,
fallbackProfileIds = listOf(builtInLaunchProfileId(TASK_FOLDER_AGENT_PROVIDER, AgentSessionLaunchMode.STANDARD)),
)
}
private fun buildTaskFolderAgentPrompt(
path: String,
folder: AgentTaskFolder?,
context: AgentPreparedNewSessionLaunchContext,
): String {
if (folder != null) {
return """
Continue this Agent Workbench task folder.
Project path: $path
Task folder name: ${folder.name}
Task folder id: ${folder.id}
Current thread id: ${context.threadId}
The IDE has already assigned this thread to the task folder. If the work needs issue tracker association, use task folder metadata key "issue".
Ask for any missing task details, then proceed with the task.
""".trimIndent()
}
return """
Start an Agent Workbench task-folder workflow for this project.
Project path: $path
Current thread id: ${context.threadId}
When you know the task folder name, call the agent_workbench_create_task_folder tool. If the user mentions an issue tracker id, pass it as the issue parameter.
If the task details are missing, ask one concise follow-up before creating the folder.
""".trimIndent()
}
private fun taskFolderAgentPathFromTarget(target: SessionActionTarget): String? {
return when (target) {
is SessionActionTarget.Project -> target.path
is SessionActionTarget.Worktree -> target.path
else -> null
}
}
private val TASK_FOLDER_AGENT_PROVIDER: AgentSessionProvider = AgentSessionProvider.from("pi")
@@ -18,6 +18,7 @@ import com.intellij.agent.workbench.sessions.service.AgentSessionProviderAvailab
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupActionContext
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupArchiveThreadAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupCopyThreadIdAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupTaskFolderAgentGroup
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupCreateTaskFolderAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupDataKeys
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupMarkTaskFolderDoneAction
@@ -27,8 +28,11 @@ import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTre
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupOpenAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupRemoveFromTaskFolderAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupRenameThreadAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupSetTaskFolderMetadataAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupToggleThreadPinAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupUnarchiveThreadAction
import com.intellij.agent.workbench.sessions.toolwindow.actions.CreateTaskFolderRequest
import com.intellij.agent.workbench.sessions.toolwindow.actions.TaskFolderMetadataEdit
import com.intellij.agent.workbench.sessions.toolwindow.actions.canMoveThreadsToTaskFolder
import com.intellij.agent.workbench.sessions.toolwindow.actions.createAgentSessionsTreePopupActionContext
import com.intellij.agent.workbench.sessions.toolwindow.actions.resolveAgentSessionsTreePopupActionContext
@@ -503,10 +507,14 @@ class AgentSessionsTreePopupActionsTest {
var createdName: String? = null
val action = AgentSessionsTreePopupCreateTaskFolderAction(
resolveContext = { event -> resolveAgentSessionsTreePopupActionContext(event) },
promptForName = { "Authentication rewrite" },
promptForCreateRequest = { _, canCreateWithAgent ->
assertThat(canCreateWithAgent).isFalse()
CreateTaskFolderRequest(name = "Authentication rewrite", createWithAgent = false)
},
createFolder = { path, name ->
createdPath = path
createdName = name
taskFolder(path = path, name = name)
},
)
@@ -535,6 +543,124 @@ class AgentSessionsTreePopupActionsTest {
assertThat(threadEvent.presentation.isEnabledAndVisible).isFalse()
}
@Test
fun createTaskFolderActionCanOpenCreatedFolderWithAgent() {
val profile = AgentPromptLaunchProfile(
id = "user:task-pi",
name = "Task Pi",
providerId = AgentSessionProvider.from("pi").value,
launchMode = AgentSessionLaunchMode.STANDARD,
)
var openedPath: String? = null
var openedFolder: AgentTaskFolder? = null
var openedProfile: AgentPromptLaunchProfile? = null
var openedProject: Project? = null
val action = AgentSessionsTreePopupCreateTaskFolderAction(
resolveContext = { event -> resolveAgentSessionsTreePopupActionContext(event) },
promptForCreateRequest = { _, canCreateWithAgent ->
assertThat(canCreateWithAgent).isTrue()
CreateTaskFolderRequest(name = "Authentication rewrite", createWithAgent = true)
},
createFolder = { path, name -> taskFolder(path = path, name = name) },
taskFolderAgentProfile = { profile },
openTaskFolderAgent = { path, folder, launchProfile, project ->
openedPath = path
openedFolder = folder
openedProfile = launchProfile
openedProject = project
},
)
val context = popupContext(
nodeId = SessionTreeId.Project("/work/project-a"),
node = SessionTreeNode.Project(AgentProjectSessions(path = "/work/project-a", name = "Project A", isOpen = true)),
)
action.actionPerformed(popupEvent(action, context))
assertThat(openedPath).isEqualTo("/work/project-a")
assertThat(openedFolder?.name).isEqualTo("Authentication rewrite")
assertThat(openedProfile).isEqualTo(profile)
assertThat(openedProject).isSameAs(context.project)
}
@Test
fun setTaskFolderMetadataActionStoresIssuePreset() {
val appliedMetadata = mutableListOf<Triple<String, String, String>>()
val action = setTaskFolderMetadataAction(
edit = TaskFolderMetadataEdit(key = "issue", value = "IJPL-248623"),
appliedMetadata = appliedMetadata,
)
val event = popupEvent(action, taskFolderPopupContext())
action.update(event)
assertThat(event.presentation.isEnabledAndVisible).isTrue()
action.actionPerformed(event)
assertThat(appliedMetadata).containsExactly(Triple("folder1", "issue", "IJPL-248623"))
}
@Test
fun setTaskFolderMetadataActionStoresReviewPreset() {
val appliedMetadata = mutableListOf<Triple<String, String, String>>()
val action = setTaskFolderMetadataAction(
edit = TaskFolderMetadataEdit(key = "review", value = "backend"),
appliedMetadata = appliedMetadata,
)
action.actionPerformed(popupEvent(action, taskFolderPopupContext()))
assertThat(appliedMetadata).containsExactly(Triple("folder1", "review", "backend"))
}
@Test
fun setTaskFolderMetadataActionStoresTrimmedCustomKey() {
val appliedMetadata = mutableListOf<Triple<String, String, String>>()
val action = setTaskFolderMetadataAction(
edit = TaskFolderMetadataEdit(key = " owner ", value = "platform"),
appliedMetadata = appliedMetadata,
)
action.actionPerformed(popupEvent(action, taskFolderPopupContext()))
assertThat(appliedMetadata).containsExactly(Triple("folder1", "owner", "platform"))
}
@Test
fun setTaskFolderMetadataActionRejectsBlankCustomKey() {
val appliedMetadata = mutableListOf<Triple<String, String, String>>()
val action = setTaskFolderMetadataAction(
edit = TaskFolderMetadataEdit(key = " ", value = "platform"),
appliedMetadata = appliedMetadata,
)
action.actionPerformed(popupEvent(action, taskFolderPopupContext()))
assertThat(appliedMetadata).isEmpty()
}
@Test
fun setTaskFolderMetadataActionVisibleOnlyForTaskFolders() {
val action = setTaskFolderMetadataAction(edit = null, appliedMetadata = mutableListOf())
val folderEvent = popupEvent(action, taskFolderPopupContext())
action.update(folderEvent)
assertThat(folderEvent.presentation.isEnabledAndVisible).isTrue()
val threadContext = popupContext(
nodeId = SessionTreeId.Thread("/work/project-a", AgentSessionProvider.from("codex"), "thread-1"),
node = SessionTreeNode.Thread(
project = AgentProjectSessions(path = "/work/project-a", name = "Project A", isOpen = true),
thread = thread(id = "thread-1", provider = AgentSessionProvider.from("codex")),
),
)
val threadEvent = popupEvent(action, threadContext)
action.update(threadEvent)
assertThat(threadEvent.presentation.isEnabledAndVisible).isFalse()
}
@Test
fun moveToTaskFolderGroupAssignsSelectedThreads() {
val provider = AgentSessionProvider.from("codex")
@@ -1172,6 +1298,97 @@ class AgentSessionsTreePopupActionsTest {
assertThat(entryPoint).isEqualTo(AgentWorkbenchEntryPoint.TREE_POPUP)
}
@Test
fun taskFolderAgentGroupFiltersToPiAndRemembersSelectedProfile() {
var launchedPath: String? = null
var launchedProfile: AgentPromptLaunchProfile? = null
var launchedProject: Project? = null
var entryPoint: AgentWorkbenchEntryPoint? = null
var rememberedProfileId: String? = null
val piProfile = AgentPromptLaunchProfile(
id = "user:pi-task-profile",
name = "Pi Task Profile",
providerId = AgentSessionProvider.from("pi").value,
launchMode = AgentSessionLaunchMode.STANDARD,
)
val codexProfile = AgentPromptLaunchProfile(
id = "user:codex-task-profile",
name = "Codex Task Profile",
providerId = AgentSessionProvider.from("codex").value,
launchMode = AgentSessionLaunchMode.STANDARD,
)
val piBridge = TestAgentSessionProviderDescriptor(
provider = AgentSessionProvider.from("pi"),
supportedModes = setOf(AgentSessionLaunchMode.STANDARD),
cliAvailable = true,
)
val codexBridge = TestAgentSessionProviderDescriptor(
provider = AgentSessionProvider.from("codex"),
supportedModes = setOf(AgentSessionLaunchMode.STANDARD),
cliAvailable = true,
)
val group = AgentSessionsTreePopupTaskFolderAgentGroup(
resolveContext = { event -> resolveAgentSessionsTreePopupActionContext(event) },
allBridges = { listOf(codexBridge, piBridge) },
userLaunchProfiles = { listOf(codexProfile, piProfile) },
taskFolderAgentLaunchProfileId = { piProfile.id },
setTaskFolderAgentLaunchProfileId = { profileId -> rememberedProfileId = profileId },
createTaskFolderAgent = { path, profile, project, capturedEntryPoint ->
launchedPath = path
launchedProfile = profile
launchedProject = project
entryPoint = capturedEntryPoint
},
)
val projectContext = popupContext(
nodeId = SessionTreeId.Project("/work/project-a"),
node = SessionTreeNode.Project(AgentProjectSessions(path = "/work/project-a", name = "Project A", isOpen = true)),
)
val event = popupEvent(group, projectContext)
group.update(event)
assertThat(event.presentation.isEnabledAndVisible).isTrue()
assertThat(event.presentation.isPerformGroup).isTrue()
group.actionPerformed(event)
assertThat(rememberedProfileId).isEqualTo(piProfile.id)
assertThat(launchedPath).isEqualTo("/work/project-a")
assertThat(launchedProfile).isEqualTo(piProfile)
assertThat(launchedProject).isSameAs(projectContext.project)
assertThat(entryPoint).isEqualTo(AgentWorkbenchEntryPoint.TREE_POPUP)
val children = group.getChildren(event).filterNot { action -> action is Separator }
assertThat(children.map { action -> action.templatePresentation.text }).contains(piProfile.name)
assertThat(children.map { action -> action.templatePresentation.text }).doesNotContain(codexProfile.name)
}
@Test
fun taskFolderAgentGroupIsVisibleButDisabledWithoutPi() {
val codexBridge = TestAgentSessionProviderDescriptor(
provider = AgentSessionProvider.from("codex"),
supportedModes = setOf(AgentSessionLaunchMode.STANDARD),
cliAvailable = true,
)
val group = AgentSessionsTreePopupTaskFolderAgentGroup(
resolveContext = { event -> resolveAgentSessionsTreePopupActionContext(event) },
allBridges = { listOf(codexBridge) },
userLaunchProfiles = { emptyList() },
)
val projectContext = popupContext(
nodeId = SessionTreeId.Project("/work/project-a"),
node = SessionTreeNode.Project(AgentProjectSessions(path = "/work/project-a", name = "Project A", isOpen = true)),
)
val event = popupEvent(group, projectContext)
group.update(event)
assertThat(event.presentation.isVisible).isTrue()
assertThat(event.presentation.isEnabled).isFalse()
assertThat(event.presentation.description)
.isEqualTo(AgentSessionsBundle.message("action.AgentWorkbenchSessions.TreePopup.TaskFolderAgent.disabled.description"))
}
}
private fun assertTogglePinHidden(action: AgentSessionsTreePopupToggleThreadPinAction, context: AgentSessionsTreePopupActionContext) {
@@ -1222,15 +1439,40 @@ private fun threadActionTarget(
)
}
private fun taskFolderPopupContext(metadata: Map<String, String> = emptyMap()): AgentSessionsTreePopupActionContext {
return popupContext(
nodeId = SessionTreeId.TaskFolder("/work/project-a", "/work/project-a", "folder1"),
node = SessionTreeNode.TaskFolder(
project = AgentProjectSessions(path = "/work/project-a", name = "Project A", isOpen = true),
folder = taskFolder(path = "/work/project-a", metadata = metadata),
assignedThreadCount = 0,
),
)
}
private fun setTaskFolderMetadataAction(
edit: TaskFolderMetadataEdit?,
appliedMetadata: MutableList<Triple<String, String, String>>,
): AgentSessionsTreePopupSetTaskFolderMetadataAction {
return AgentSessionsTreePopupSetTaskFolderMetadataAction(
resolveContext = { event -> resolveAgentSessionsTreePopupActionContext(event) },
promptForMetadata = { _, _ -> edit },
setMetadata = { target, key, value -> appliedMetadata += Triple(target.folderId, key, value) },
)
}
private fun taskFolder(
path: String,
name: String = "Research",
status: AgentTaskFolderStatus = AgentTaskFolderStatus.IN_PROGRESS,
metadata: Map<String, String> = emptyMap(),
): AgentTaskFolder {
return AgentTaskFolder(
path = path,
id = "folder1",
name = "Research",
name = name,
status = status,
metadata = metadata,
createdAt = 1,
updatedAt = 1,
)
@@ -74,6 +74,8 @@ toolwindow.thread.status.done=Done
toolwindow.task.folder.thread.count={0,choice,1#1 thread|2#{0} threads}
toolwindow.task.folder.create.dialog.title=New Task Folder
toolwindow.task.folder.create.dialog.message=Folder name:
toolwindow.task.folder.create.dialog.create=Create
toolwindow.task.folder.create.dialog.with.agent=Create with Agent
toolwindow.task.folder.rename.dialog.title=Rename Task Folder
toolwindow.task.folder.rename.dialog.message=Folder name:
toolwindow.task.folder.delete.dialog.title=Delete Task Folder
@@ -82,6 +84,7 @@ toolwindow.task.folder.dialog.validation.empty=Folder name must not be empty.
toolwindow.task.folder.metadata.set.dialog.title=Set Task Metadata
toolwindow.task.folder.metadata.key.dialog.message=Metadata key:
toolwindow.task.folder.metadata.value.dialog.message=Metadata value:
toolwindow.task.folder.metadata.key.validation.empty=Metadata key must not be empty.
toolwindow.task.folder.metadata.delete.dialog.title=Delete Task Metadata
toolwindow.task.folder.metadata.delete.dialog.message=Metadata key:
toolwindow.activity.action.attention.tooltip=Threads waiting for input or review
@@ -156,6 +159,9 @@ group.AgentWorkbenchSessions.TreePopup.NewThread.text=New Thread
group.AgentWorkbenchSessions.TreePopup.NewThread.description=Start a new thread
action.AgentWorkbenchSessions.TreePopup.CreateTaskFolder.text=New Task Folder
action.AgentWorkbenchSessions.TreePopup.CreateTaskFolder.description=Create a task folder for this project or worktree
group.AgentWorkbenchSessions.TreePopup.TaskFolderAgent.text=New Task Folder with Agent
group.AgentWorkbenchSessions.TreePopup.TaskFolderAgent.description=Create a task folder with an agent
action.AgentWorkbenchSessions.TreePopup.TaskFolderAgent.disabled.description=Pi is required to create a task folder with an agent.
group.AgentWorkbenchSessions.TreePopup.MoveToTaskFolder.text=Move to Task Folder
group.AgentWorkbenchSessions.TreePopup.MoveToTaskFolder.description=Move selected threads to a task folder
action.AgentWorkbenchSessions.TreePopup.RemoveFromTaskFolder.text=Remove from Task Folder
@@ -221,6 +221,14 @@ data class AgentDeferredNewSessionLaunchResult(
@JvmField val error: AgentPromptLaunchError? = null,
)
data class AgentPreparedNewSessionLaunchContext(
@JvmField val projectPath: String,
val provider: AgentSessionProvider,
@JvmField val threadId: String,
@JvmField val identity: String,
@JvmField val launchProfileId: String?,
)
interface AgentDeferredNewSessionHandle {
val file: VirtualFile
@@ -793,6 +801,8 @@ class AgentSessionLaunchService internal constructor(
entryPoint: AgentWorkbenchEntryPoint,
currentProject: Project? = null,
initialMessageRequest: AgentPromptInitialMessageRequest? = null,
initialMessageRequestBuilder: ((AgentPreparedNewSessionLaunchContext) -> AgentPromptInitialMessageRequest?)? = null,
preparedLaunchHandler: ((AgentPreparedNewSessionLaunchContext) -> Unit)? = null,
preferredDedicatedFrame: Boolean? = null,
openedChatHandler: (suspend (Project, VirtualFile) -> Unit)? = null,
promptLaunchResolved: ((AgentPromptLaunchResult) -> Unit)? = null,
@@ -817,6 +827,8 @@ class AgentSessionLaunchService internal constructor(
entryPoint = entryPoint,
currentProject = currentProject,
initialMessageRequest = initialMessageRequest,
initialMessageRequestBuilder = initialMessageRequestBuilder,
preparedLaunchHandler = preparedLaunchHandler,
preferredDedicatedFrame = preferredDedicatedFrame,
openedChatHandler = openedChatHandler,
promptLaunchResolved = promptLaunchResolved,
@@ -839,6 +851,8 @@ class AgentSessionLaunchService internal constructor(
entryPoint: AgentWorkbenchEntryPoint,
currentProject: Project? = null,
initialMessageRequest: AgentPromptInitialMessageRequest? = null,
initialMessageRequestBuilder: ((AgentPreparedNewSessionLaunchContext) -> AgentPromptInitialMessageRequest?)? = null,
preparedLaunchHandler: ((AgentPreparedNewSessionLaunchContext) -> Unit)? = null,
preferredDedicatedFrame: Boolean? = null,
openedChatHandler: (suspend (Project, VirtualFile) -> Unit)? = null,
promptLaunchResolved: ((AgentPromptLaunchResult) -> Unit)? = null,
@@ -898,6 +912,8 @@ class AgentSessionLaunchService internal constructor(
launchProfileId = effectiveLaunchProfileId,
currentProject = currentProject,
initialMessageRequest = initialMessageRequest,
initialMessageRequestBuilder = initialMessageRequestBuilder,
preparedLaunchHandler = preparedLaunchHandler,
updateGeneralProviderPreferences = updateGeneralProviderPreferences,
generationSettings = effectiveGenerationSettings,
generationModelCatalog = generationModelCatalog,
@@ -1194,6 +1210,8 @@ class AgentSessionLaunchService internal constructor(
launchProfileId: String?,
currentProject: Project?,
initialMessageRequest: AgentPromptInitialMessageRequest?,
initialMessageRequestBuilder: ((AgentPreparedNewSessionLaunchContext) -> AgentPromptInitialMessageRequest?)? = null,
preparedLaunchHandler: ((AgentPreparedNewSessionLaunchContext) -> Unit)? = null,
updateGeneralProviderPreferences: Boolean,
generationSettings: AgentPromptGenerationSettings,
generationModelCatalog: List<AgentPromptGenerationModel> = emptyList(),
@@ -1220,13 +1238,12 @@ class AgentSessionLaunchService internal constructor(
return NewSessionLaunchPreparationResult.Failed(AgentPromptLaunchError.PROVIDER_UNAVAILABLE)
}
notifyAgentSessionConversationOpened(descriptor)
if (updateGeneralProviderPreferences && descriptor.supportsPromptLaunch) {
uiPreferencesState.updateProviderOptionsOnLaunch(provider.value, initialMessageRequest)
val staticInitialMessagePlan = if (initialMessageRequestBuilder == null) {
initialMessageRequest?.let(descriptor::buildInitialMessagePlan) ?: AgentInitialMessagePlan.EMPTY
}
else {
AgentInitialMessagePlan.EMPTY
}
val initialMessagePlan = initialMessageRequest
?.let(descriptor::buildInitialMessagePlan)
?: AgentInitialMessagePlan.EMPTY
val projectDirectory = resolveLaunchProjectDirectory(path = normalizedPath, currentProject = currentProject, stateStore = stateStore)
val plannedLaunch = AgentSessionLaunchPlanner.plan(
intent = AgentSessionLaunchIntent(
@@ -1238,13 +1255,35 @@ class AgentSessionLaunchService internal constructor(
generationSettings = generationSettings,
),
project = currentProject,
initialMessagePlan = initialMessagePlan,
initialMessagePlan = staticInitialMessagePlan,
generationModelCatalog = generationModelCatalog,
extraEnvVariables = extraEnvVariables,
extraCommandArgs = extraCommandArgs,
)
val baseLaunchSpec = plannedLaunch.baseLaunchSpec
val plannedLaunchSpec = plannedLaunch.launchSpec
val preliminaryIdentity = buildNewSessionIdentity(
provider = provider,
launchSpec = plannedLaunchSpec,
fallbackPendingIdentity = fallbackPendingIdentity,
)
val preliminaryContext = AgentPreparedNewSessionLaunchContext(
projectPath = normalizedPath,
provider = provider,
threadId = resolveAgentSessionId(preliminaryIdentity),
identity = preliminaryIdentity,
launchProfileId = launchProfileId,
)
val effectiveInitialMessageRequest = initialMessageRequestBuilder?.invoke(preliminaryContext) ?: initialMessageRequest
if (updateGeneralProviderPreferences && descriptor.supportsPromptLaunch) {
uiPreferencesState.updateProviderOptionsOnLaunch(provider.value, effectiveInitialMessageRequest)
}
val initialMessagePlan = if (initialMessageRequestBuilder == null) {
staticInitialMessagePlan
}
else {
effectiveInitialMessageRequest?.let(descriptor::buildInitialMessagePlan) ?: AgentInitialMessagePlan.EMPTY
}
val prestartedLaunch = descriptor.prestartNewSessionLaunch(
projectPath = normalizedPath,
launchMode = mode,
@@ -1255,8 +1294,21 @@ class AgentSessionLaunchService internal constructor(
)
val launchSpec = prestartedLaunch?.launchSpec ?: plannedLaunchSpec
val identity = buildNewSessionIdentity(provider = provider, launchSpec = launchSpec, fallbackPendingIdentity = fallbackPendingIdentity)
val preparedContext = if (identity == preliminaryContext.identity) {
preliminaryContext
}
else {
AgentPreparedNewSessionLaunchContext(
projectPath = normalizedPath,
provider = provider,
threadId = resolveAgentSessionId(identity),
identity = identity,
launchProfileId = launchProfileId,
)
}
preparedLaunchHandler?.invoke(preparedContext)
val initialMessageDispatchPlan = prestartedLaunch?.initialMessageDispatchPlan
?: buildInitialMessageDispatchPlan(
?: buildInitialMessageDispatchPlan(
descriptor = descriptor,
baseLaunchSpec = launchSpec,
identity = identity,
@@ -27,6 +27,16 @@ class AgentSessionUiPreferencesStateService(
return launchProfileStateService.getDefaultLaunchProfileId()
}
fun getTaskFolderAgentLaunchProfileId(): String? {
return state.taskFolderAgentLaunchProfileId
}
fun setTaskFolderAgentLaunchProfileId(profileId: String?) {
updateState { current ->
current.copy(taskFolderAgentLaunchProfileId = profileId?.trim()?.takeIf { it.isNotEmpty() })
}
}
fun getProviderPreferences(): AgentPromptLauncherBridge.ProviderPreferences {
return AgentPromptLauncherBridge.ProviderPreferences(
providerOptionsByProviderId = state.providerOptionsByProviderId,
@@ -72,5 +82,6 @@ class AgentSessionUiPreferencesStateService(
data class UiPreferencesState(
@JvmField val providerOptionsByProviderId: Map<String, Set<String>> = emptyMap(),
@JvmField val containerModeEnabled: Boolean = false,
@JvmField val taskFolderAgentLaunchProfileId: String? = null,
)
}
@@ -581,7 +581,7 @@ class AgentSessionLaunchServiceTest {
provider = AgentSessionProvider.from("codex"),
supportedModes = setOf(AgentSessionLaunchMode.STANDARD),
cliAvailable = true,
newSessionLaunchSpecProvider = {
newSessionLaunchSpecProvider = { _ ->
val attempt = launchSpecAttempts.incrementAndGet()
AgentSessionTerminalLaunchSpec(command = listOf("test", "retry", attempt.toString()))
},
@@ -807,6 +807,57 @@ class AgentSessionLaunchServiceTest {
}
}
@Test
fun createNewSessionBuilderAndPreparedHandlerUsePreallocatedLaunchSpecSessionId() {
val provider = AgentSessionProvider.from("pi")
val preallocatedSessionId = "f174b4df-e942-49fe-bb30-8b5f8e7f4857"
val builderThreadIds = CopyOnWriteArrayList<String>()
val preparedThreadIds = CopyOnWriteArrayList<String>()
val descriptor = TestAgentSessionProviderDescriptor(
provider = provider,
supportedModes = setOf(AgentSessionLaunchMode.STANDARD),
cliAvailable = true,
newSessionLaunchSpecProvider = {
AgentSessionTerminalLaunchSpec(
command = listOf("test", "new"),
preallocatedSessionId = preallocatedSessionId,
)
},
)
val chatOpenExecutor = RecordingChatOpenExecutor()
AgentSessionProviders.withRegistryForTest(InMemoryAgentSessionProviderRegistry(listOf(descriptor))) {
runBlocking(Dispatchers.Default) {
withTestServiceAndLaunch(
sessionSourcesProvider = { listOf(ScriptedSessionSource(provider = provider)) },
projectEntriesProvider = { listOf(openTestProjectEntry(PROJECT_PATH, "Project A")) },
chatOpenExecutor = chatOpenExecutor,
) { _, launchService ->
launchService.createNewSession(
path = PROJECT_PATH,
provider = provider,
entryPoint = AgentWorkbenchEntryPoint.TREE_POPUP,
initialMessageRequestBuilder = { context ->
builderThreadIds += context.threadId
AgentPromptInitialMessageRequest(prompt = "Start task folder ${context.threadId}")
},
preparedLaunchHandler = { context ->
preparedThreadIds += context.threadId
},
)
waitForCondition { chatOpenExecutor.openNewChatCalls.get() == 1 }
val openRequest = checkNotNull(chatOpenExecutor.lastOpenNewChatRequest.get())
assertThat(openRequest.identity).isEqualTo(buildAgentSessionIdentity(provider, preallocatedSessionId))
assertThat(openRequest.initialComposedMessage).isEqualTo("Start task folder $preallocatedSessionId")
assertThat(builderThreadIds).containsExactly(preallocatedSessionId)
assertThat(preparedThreadIds).containsExactly(preallocatedSessionId)
}
}
}
}
@Test
fun openCodexThreadRebindsMatchingPendingTabBeforeOpening() {
assertOpenThreadRebindsMatchingPendingTabBeforeOpening(AgentSessionProvider.from("codex"))
@@ -91,8 +91,8 @@ Agent Workbench treats Pi as a first-class terminal-backed provider. Pi sessions
[@test] ../../lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt
- The same authenticated control WebSocket may carry explicit task-folder capability requests from the bundled Pi extension to the IDE:
`getCurrentTaskFolder`, `listTaskFolderThreads`, `getTaskFolderMetadata`, `setTaskFolderMetadata`, and
`deleteTaskFolderMetadata`. These requests must use the bound cwd/session id to resolve current-session context, use global folder id
`getCurrentTaskFolder`, `listTaskFolderThreads`, `createAndAssignTaskFolder`, `getTaskFolderMetadata`,
`setTaskFolderMetadata`, and `deleteTaskFolderMetadata`. These requests must use the bound cwd/session id to resolve current-session context, use global folder id
for explicit folder mutations, must not inject prompt context automatically, and must return normal `response` frames with `ok`,
`requestId`, and the requested folder, assignment, metadata, or mutation result fields.
[@test] ../../lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt
@@ -51,7 +51,9 @@ create a new top-level Agent Workbench entity and do not inject context into pro
- Project and worktree rows must offer `New Task Folder`. Thread rows must offer `Move to Task Folder` for in-progress folders on the same
path and `Remove from Task Folder` for assigned threads. Folder rows must offer rename, delete, explicit metadata set/delete, and mark
done actions. User-visible action text and dialog text must live in `AgentSessionsBundle.properties`.
done actions. The metadata set dialog must expose an editable metadata key combo with `issue` and `review` key presets while persisting
ordinary string key/value metadata. Custom typed keys are stored as entered after trimming.
User-visible action text and dialog text must live in `AgentSessionsBundle.properties`.
[@test] ../../sessions-toolwindow/testSrc/AgentSessionsTreePopupActionsTest.kt
- Dragging active thread rows onto an in-progress task folder on the same path must perform the same move as `Move to Task Folder`. If the