From 141d0e22d9232e55b3aa2dc921a26634f840e35d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:47:27 +0200 Subject: [PATCH] feat(team): implement startTaskByUser functionality - Added a new IPC handler for starting tasks triggered by users, ensuring that the task owner is always notified. - Introduced `startTaskByUser` method in `TeamDataService` to handle task initiation and notifications. - Updated relevant components and API interfaces to support the new functionality, including changes in the UI to call `startTaskByUser` instead of the previous `startTask`. - Documented agent block usage for internal instructions in CLAUDE.md. This enhancement improves user interaction with task management by providing a clear mechanism for user-initiated task starts. --- CLAUDE.md | 7 +++ src/main/ipc/teams.ts | 21 +++++++ src/main/services/team/TeamDataService.ts | 56 +++++++++++++++++++ .../services/team/TeamProvisioningService.ts | 8 +-- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 8 +++ src/renderer/api/httpClient.ts | 6 ++ .../components/team/TeamDetailView.tsx | 6 +- src/renderer/store/slices/teamSlice.ts | 9 +++ src/shared/constants/agentBlocks.ts | 10 ++++ src/shared/types/api.ts | 1 + 11 files changed, 127 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0445ba0f..adfa3ce2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,13 @@ Path encoding: `/Users/name/project` → `-Users-name-project` ## Critical Concepts +### Agent Blocks +- Use `wrapAgentBlock(text)` from `@shared/constants/agentBlocks` to wrap agent-only content. + Do NOT manually concatenate `AGENT_BLOCK_OPEN/CLOSE` — the wrapper handles trimming and formatting. +- `stripAgentBlocks(text)` — removes agent blocks for UI display +- `unwrapAgentBlock(block)` — extracts content from a single block +- Agent blocks are hidden from the user in UI, used for internal instructions between agents. + ### isMeta Flag - `isMeta: false` = Real user message (creates new chunks) - `isMeta: true` = Internal message (tool results, system-generated) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f28bb017..1b12e3b9 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -50,6 +50,7 @@ import { TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, + TEAM_START_TASK_BY_USER, TEAM_STOP, TEAM_TOOL_APPROVAL_READ_FILE, TEAM_TOOL_APPROVAL_RESPOND, @@ -332,6 +333,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); + ipcMain.handle(TEAM_START_TASK_BY_USER, handleStartTaskByUser); ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks); ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember); @@ -393,6 +395,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); + ipcMain.removeHandler(TEAM_START_TASK_BY_USER); ipcMain.removeHandler(TEAM_GET_ALL_TASKS); ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_MEMBER); @@ -2116,6 +2119,24 @@ async function handleStartTask( ); } +async function handleStartTaskByUser( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedTaskId = validateTaskId(taskId); + if (!validatedTaskId.valid) { + return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('startTaskByUser', () => + getTeamDataService().startTaskByUser(validatedTeamName.value!, validatedTaskId.value!) + ); +} + async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise> { setCurrentMainOp('team:getAllTasks'); const startedAt = Date.now(); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0070ae59..56e7fb13 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -12,6 +12,7 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, stripAgentBlocks, + wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; @@ -927,6 +928,61 @@ export class TeamDataService { return { notifiedOwner: !!task.owner }; } + /** + * Start a task triggered by the user via UI. + * Unlike startTask(), this always notifies the owner (including the lead in solo teams). + */ + async startTaskByUser(teamName: string, taskId: string): Promise<{ notifiedOwner: boolean }> { + const tasks = await this.taskReader.getTasks(teamName); + const task = tasks.find((t) => t.id === taskId); + if (!task) { + throw new Error(`Task #${taskId} not found`); + } + if (task.status !== 'pending') { + throw new Error(`Task #${taskId} is not pending (current: ${task.status})`); + } + + this.getController(teamName).tasks.startTask(taskId, 'user'); + + if (task.owner) { + try { + const parts = [ + `**start working on task now** ${this.getTaskLabel(task)} "${task.subject}"`, + ]; + if (task.description?.trim()) { + parts.push(`\nDetails:\n${task.description.trim()}`); + } + if (task.prompt?.trim()) { + parts.push(`\nInstructions:\n${task.prompt.trim()}`); + } + parts.push( + '', + wrapAgentBlock( + [ + `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, + `To fetch the full task context (description, comments, attachments) use:`, + `task_get { teamName: "${teamName}", taskId: "${task.id}" }`, + `When done, update task status:`, + `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, + ].join('\n') + ) + ); + await this.sendMessage(teamName, { + member: task.owner, + from: 'user', + text: parts.join('\n'), + taskRefs: task.descriptionTaskRefs, + summary: `Start working on ${this.getTaskLabel(task)}`, + source: 'system_notification', + }); + } catch { + // Best-effort notification + } + } + + return { notifiedOwner: !!task.owner }; + } + async updateTaskStatus( teamName: string, taskId: string, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2eeaa9db..f5ef1501 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,6 +19,7 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, stripAgentBlocks, + wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_PREFIX_TAG, @@ -387,11 +388,8 @@ async function ensureCwdExists(cwd: string): Promise { } } -function wrapInAgentBlock(text: string): string { - const trimmed = text.trim(); - if (trimmed.length === 0) return ''; - return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; -} +/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */ +const wrapInAgentBlock = wrapAgentBlock; function indentMultiline(text: string, indent: string): string { return text diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 3f4b47db..7ced81a6 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -298,6 +298,9 @@ export const TEAM_GET_MEMBER_STATS = 'team:getMemberStats'; /** Start a pending task (transition to in_progress + notify agent) */ export const TEAM_START_TASK = 'team:startTask'; +/** Start a pending task from UI — always notifies owner (including lead in solo teams) */ +export const TEAM_START_TASK_BY_USER = 'team:startTaskByUser'; + /** Get all tasks across all teams */ export const TEAM_GET_ALL_TASKS = 'team:getAllTasks'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 6a5eda34..e9f97771 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -152,6 +152,7 @@ import { TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, + TEAM_START_TASK_BY_USER, TEAM_STOP, TEAM_TOOL_APPROVAL_EVENT, TEAM_TOOL_APPROVAL_READ_FILE, @@ -872,6 +873,13 @@ const electronAPI: ElectronAPI = { startTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId); }, + startTaskByUser: async (teamName: string, taskId: string) => { + return invokeIpcWithResult<{ notifiedOwner: boolean }>( + TEAM_START_TASK_BY_USER, + teamName, + taskId + ); + }, processSend: async (teamName: string, message: string) => { return invokeIpcWithResult(TEAM_PROCESS_SEND, teamName, message); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index a33577a2..2160f340 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -756,6 +756,12 @@ export class HttpAPIClient implements ElectronAPI { startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => { throw new Error('Team start task is not available in browser mode'); }, + startTaskByUser: async ( + _teamName: string, + _taskId: string + ): Promise<{ notifiedOwner: boolean }> => { + throw new Error('Team start task by user is not available in browser mode'); + }, processSend: async (_teamName: string, _message: string): Promise => { throw new Error('Team process communication is not available in browser mode'); }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e6605826..875a1fa8 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -261,7 +261,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendTeamMessage, requestReview, createTeamTask, - startTask, + startTaskByUser, deleteTeam, openTeamsTab, closeTab, @@ -310,7 +310,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendTeamMessage: s.sendTeamMessage, requestReview: s.requestReview, createTeamTask: s.createTeamTask, - startTask: s.startTask, + startTaskByUser: s.startTaskByUser, deleteTeam: s.deleteTeam, openTeamsTab: s.openTeamsTab, closeTab: s.closeTab, @@ -1537,7 +1537,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onStartTask={(taskId) => { void (async () => { try { - const result = await startTask(teamName, taskId); + const result = await startTaskByUser(teamName, taskId); if (data?.isAlive) { const task = data.tasks.find((t) => t.id === taskId); try { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d33f2a4e..7d84b85c 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -583,6 +583,7 @@ export interface TeamSlice { ) => Promise; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; + startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise; updateTaskFields: ( @@ -1443,6 +1444,14 @@ export const createTeamSlice: StateCreator = (set, return result; }, + startTaskByUser: async (teamName: string, taskId: string) => { + const result = await unwrapIpc('team:startTaskByUser', () => + api.teams.startTaskByUser(teamName, taskId) + ); + await get().refreshTeamData(teamName); + return result; + }, + updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => { await unwrapIpc('team:updateTaskStatus', () => api.teams.updateTaskStatus(teamName, taskId, status) diff --git a/src/shared/constants/agentBlocks.ts b/src/shared/constants/agentBlocks.ts index 3d596c39..09aa655e 100644 --- a/src/shared/constants/agentBlocks.ts +++ b/src/shared/constants/agentBlocks.ts @@ -81,6 +81,16 @@ export function extractAgentBlockContents(text: string): string[] { */ export const AGENT_BLOCK_REGEX = new RegExp(AGENT_BLOCK_PATTERN, 'g'); +/** + * Wraps text in agent-only block markers. + * Use this instead of manually concatenating AGENT_BLOCK_OPEN/CLOSE. + */ +export function wrapAgentBlock(text: string): string { + const trimmed = text.trim(); + if (trimmed.length === 0) return ''; + return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; +} + /** * Fenced code block marker for reply messages between agents. * diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 8451ab8b..5bae8b21 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -443,6 +443,7 @@ export interface TeamsAPI { fields: { subject?: string; description?: string } ) => Promise; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; + startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; processSend: (teamName: string, message: string) => Promise; processAlive: (teamName: string) => Promise; aliveList: () => Promise;