diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts index 6c7a401a6e8f..57f42ad772c5 100644 --- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts +++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts @@ -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; }; diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts index b8cf5f45fd75..b9c2545fa021 100644 --- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts +++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts @@ -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; listTaskFolderThreads: (folderId?: string) => Promise; + createAndAssignTaskFolder: (name: string, metadata?: Record) => Promise; getTaskFolderMetadata: (folderId?: string) => Promise | undefined>; setTaskFolderMetadata: (key: string, value: string, folderId?: string) => Promise; deleteTaskFolderMetadata: (key: string, folderId?: string) => Promise; @@ -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; + 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; 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 { diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt index 734001d19d7b..ce7c971c5418 100644 --- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt +++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt @@ -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, diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt index df4c51a17ca0..9f150619048e 100644 --- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt +++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt @@ -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? = 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? = 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? { + if (parser.currentToken() != JsonToken.START_OBJECT) { + parser.skipChildren() + return null + } + val result = LinkedHashMap() + 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()) { diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt index 7b6ae2f44fad..fd15c98356b4 100644 --- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt +++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt @@ -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) diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt index e5a2d55e4213..fd831ceebe92 100644 --- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt +++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt @@ -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? = 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(",")}}" } diff --git a/plugins/agent-workbench/sessions-toolwindow/resources/intellij.agent.workbench.sessions.toolwindow.xml b/plugins/agent-workbench/sessions-toolwindow/resources/intellij.agent.workbench.sessions.toolwindow.xml index f1c05c332291..58fd5224c502 100644 --- a/plugins/agent-workbench/sessions-toolwindow/resources/intellij.agent.workbench.sessions.toolwindow.xml +++ b/plugins/agent-workbench/sessions-toolwindow/resources/intellij.agent.workbench.sessions.toolwindow.xml @@ -49,6 +49,11 @@ id="AgentWorkbenchSessions.TreePopup.CreateTaskFolder" icon="AllIcons.General.Add" class="com.intellij.agent.workbench.sessions.toolwindow.actions.AgentSessionsTreePopupCreateTaskFolderAction"/> + + diff --git a/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderActions.kt b/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderActions.kt index f1dd00159d1c..2b99829cb13a 100644 --- a/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderActions.kt +++ b/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderActions.kt @@ -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().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? = - ::showSetTaskFolderMetadataDialog - private val setMetadata: (SessionActionTarget.TaskFolder, String, String) -> Unit = { target, key, value -> - service().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().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? { - 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 { + 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): 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): @NlsSafe String { + return TASK_FOLDER_METADATA_KEY_PRESETS.firstOrNull(metadata::containsKey) + ?: metadata.keys.firstOrNull { key -> key.isNotBlank() } + ?: TASK_FOLDER_METADATA_KEY_PRESETS.first() +} diff --git a/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderAgentActions.kt b/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderAgentActions.kt new file mode 100644 index 000000000000..5cedd94bac07 --- /dev/null +++ b/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderAgentActions.kt @@ -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 = AgentSessionProviders::allProviders, + private val userLaunchProfiles: () -> List = { service().getUserLaunchProfiles() }, + private val taskFolderAgentLaunchProfileId: () -> String? = { + service().getTaskFolderAgentLaunchProfileId() + }, + private val setTaskFolderAgentLaunchProfileId: (String?) -> Unit = { profileId -> + service().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 { + 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().getUserLaunchProfiles(), + preferredProfileId = service().getTaskFolderAgentLaunchProfileId(), + ) + return selection.quickStartItem + ?.takeIf { item -> item.menuItem.isEnabled } + ?.profile +} + +internal fun createTaskFolderAgentViaService( + path: String, + profile: AgentPromptLaunchProfile, + project: Project, + entryPoint: AgentWorkbenchEntryPoint, + folder: AgentTaskFolder?, +) { + service().setTaskFolderAgentLaunchProfileId(profile.id) + service().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().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, + userLaunchProfiles: List, + 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") diff --git a/plugins/agent-workbench/sessions-toolwindow/testSrc/AgentSessionsTreePopupActionsTest.kt b/plugins/agent-workbench/sessions-toolwindow/testSrc/AgentSessionsTreePopupActionsTest.kt index db26aad0ea4f..56b0e9ba483a 100644 --- a/plugins/agent-workbench/sessions-toolwindow/testSrc/AgentSessionsTreePopupActionsTest.kt +++ b/plugins/agent-workbench/sessions-toolwindow/testSrc/AgentSessionsTreePopupActionsTest.kt @@ -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>() + 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>() + 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>() + 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>() + 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 = 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>, +): 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 = emptyMap(), ): AgentTaskFolder { return AgentTaskFolder( path = path, id = "folder1", - name = "Research", + name = name, status = status, + metadata = metadata, createdAt = 1, updatedAt = 1, ) diff --git a/plugins/agent-workbench/sessions/resources/messages/AgentSessionsBundle.properties b/plugins/agent-workbench/sessions/resources/messages/AgentSessionsBundle.properties index af7d2fc59da9..5d3cf866063a 100644 --- a/plugins/agent-workbench/sessions/resources/messages/AgentSessionsBundle.properties +++ b/plugins/agent-workbench/sessions/resources/messages/AgentSessionsBundle.properties @@ -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 diff --git a/plugins/agent-workbench/sessions/src/service/AgentSessionLaunchService.kt b/plugins/agent-workbench/sessions/src/service/AgentSessionLaunchService.kt index 32d396882ae7..3ec9c2e3f458 100644 --- a/plugins/agent-workbench/sessions/src/service/AgentSessionLaunchService.kt +++ b/plugins/agent-workbench/sessions/src/service/AgentSessionLaunchService.kt @@ -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 = 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, diff --git a/plugins/agent-workbench/sessions/src/state/AgentSessionUiPreferencesStateService.kt b/plugins/agent-workbench/sessions/src/state/AgentSessionUiPreferencesStateService.kt index af4aebe3726f..f0f7f5387da4 100644 --- a/plugins/agent-workbench/sessions/src/state/AgentSessionUiPreferencesStateService.kt +++ b/plugins/agent-workbench/sessions/src/state/AgentSessionUiPreferencesStateService.kt @@ -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> = emptyMap(), @JvmField val containerModeEnabled: Boolean = false, + @JvmField val taskFolderAgentLaunchProfileId: String? = null, ) } diff --git a/plugins/agent-workbench/sessions/testSrc/AgentSessionLaunchServiceTest.kt b/plugins/agent-workbench/sessions/testSrc/AgentSessionLaunchServiceTest.kt index 8c5174dab282..967a07ec105c 100644 --- a/plugins/agent-workbench/sessions/testSrc/AgentSessionLaunchServiceTest.kt +++ b/plugins/agent-workbench/sessions/testSrc/AgentSessionLaunchServiceTest.kt @@ -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() + val preparedThreadIds = CopyOnWriteArrayList() + 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")) diff --git a/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md b/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md index f404bff43254..0584d8fc4eca 100644 --- a/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md +++ b/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md @@ -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 diff --git a/plugins/agent-workbench/spec/sessions/agent-task-folders.spec.md b/plugins/agent-workbench/spec/sessions/agent-task-folders.spec.md index 1d46173b986c..22d2a25924e4 100644 --- a/plugins/agent-workbench/spec/sessions/agent-task-folders.spec.md +++ b/plugins/agent-workbench/spec/sessions/agent-task-folders.spec.md @@ -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