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.
This commit is contained in:
iliya 2026-03-25 14:47:27 +02:00
parent 7324b5236d
commit 141d0e22d9
11 changed files with 127 additions and 8 deletions

View file

@ -64,6 +64,13 @@ Path encoding: `/Users/name/project` → `-Users-name-project`
## Critical Concepts ## 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 Flag
- `isMeta: false` = Real user message (creates new chunks) - `isMeta: false` = Real user message (creates new chunks)
- `isMeta: true` = Internal message (tool results, system-generated) - `isMeta: true` = Internal message (tool results, system-generated)

View file

@ -50,6 +50,7 @@ import {
TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK, TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK, TEAM_START_TASK,
TEAM_START_TASK_BY_USER,
TEAM_STOP, TEAM_STOP,
TEAM_TOOL_APPROVAL_READ_FILE, TEAM_TOOL_APPROVAL_READ_FILE,
TEAM_TOOL_APPROVAL_RESPOND, TEAM_TOOL_APPROVAL_RESPOND,
@ -332,6 +333,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
ipcMain.handle(TEAM_START_TASK, handleStartTask); ipcMain.handle(TEAM_START_TASK, handleStartTask);
ipcMain.handle(TEAM_START_TASK_BY_USER, handleStartTaskByUser);
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks); ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember); 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_GET_MEMBER_STATS);
ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
ipcMain.removeHandler(TEAM_START_TASK); ipcMain.removeHandler(TEAM_START_TASK);
ipcMain.removeHandler(TEAM_START_TASK_BY_USER);
ipcMain.removeHandler(TEAM_GET_ALL_TASKS); ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
ipcMain.removeHandler(TEAM_ADD_MEMBER); ipcMain.removeHandler(TEAM_ADD_MEMBER);
@ -2116,6 +2119,24 @@ async function handleStartTask(
); );
} }
async function handleStartTaskByUser(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<{ notifiedOwner: boolean }>> {
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<IpcResult<GlobalTask[]>> { async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
setCurrentMainOp('team:getAllTasks'); setCurrentMainOp('team:getAllTasks');
const startedAt = Date.now(); const startedAt = Date.now();

View file

@ -12,6 +12,7 @@ import {
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
AGENT_BLOCK_OPEN, AGENT_BLOCK_OPEN,
stripAgentBlocks, stripAgentBlocks,
wrapAgentBlock,
} from '@shared/constants/agentBlocks'; } from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
@ -927,6 +928,61 @@ export class TeamDataService {
return { notifiedOwner: !!task.owner }; 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( async updateTaskStatus(
teamName: string, teamName: string,
taskId: string, taskId: string,

View file

@ -19,6 +19,7 @@ import {
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
AGENT_BLOCK_OPEN, AGENT_BLOCK_OPEN,
stripAgentBlocks, stripAgentBlocks,
wrapAgentBlock,
} from '@shared/constants/agentBlocks'; } from '@shared/constants/agentBlocks';
import { import {
CROSS_TEAM_PREFIX_TAG, CROSS_TEAM_PREFIX_TAG,
@ -387,11 +388,8 @@ async function ensureCwdExists(cwd: string): Promise<void> {
} }
} }
function wrapInAgentBlock(text: string): string { /** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */
const trimmed = text.trim(); const wrapInAgentBlock = wrapAgentBlock;
if (trimmed.length === 0) return '';
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
}
function indentMultiline(text: string, indent: string): string { function indentMultiline(text: string, indent: string): string {
return text return text

View file

@ -298,6 +298,9 @@ export const TEAM_GET_MEMBER_STATS = 'team:getMemberStats';
/** Start a pending task (transition to in_progress + notify agent) */ /** Start a pending task (transition to in_progress + notify agent) */
export const TEAM_START_TASK = 'team:startTask'; 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 */ /** Get all tasks across all teams */
export const TEAM_GET_ALL_TASKS = 'team:getAllTasks'; export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';

View file

@ -152,6 +152,7 @@ import {
TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK, TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK, TEAM_START_TASK,
TEAM_START_TASK_BY_USER,
TEAM_STOP, TEAM_STOP,
TEAM_TOOL_APPROVAL_EVENT, TEAM_TOOL_APPROVAL_EVENT,
TEAM_TOOL_APPROVAL_READ_FILE, TEAM_TOOL_APPROVAL_READ_FILE,
@ -872,6 +873,13 @@ const electronAPI: ElectronAPI = {
startTask: async (teamName: string, taskId: string) => { startTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId); 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) => { processSend: async (teamName: string, message: string) => {
return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message); return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message);
}, },

View file

@ -756,6 +756,12 @@ export class HttpAPIClient implements ElectronAPI {
startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => { startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => {
throw new Error('Team start task is not available in browser mode'); 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<void> => { processSend: async (_teamName: string, _message: string): Promise<void> => {
throw new Error('Team process communication is not available in browser mode'); throw new Error('Team process communication is not available in browser mode');
}, },

View file

@ -261,7 +261,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendTeamMessage, sendTeamMessage,
requestReview, requestReview,
createTeamTask, createTeamTask,
startTask, startTaskByUser,
deleteTeam, deleteTeam,
openTeamsTab, openTeamsTab,
closeTab, closeTab,
@ -310,7 +310,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendTeamMessage: s.sendTeamMessage, sendTeamMessage: s.sendTeamMessage,
requestReview: s.requestReview, requestReview: s.requestReview,
createTeamTask: s.createTeamTask, createTeamTask: s.createTeamTask,
startTask: s.startTask, startTaskByUser: s.startTaskByUser,
deleteTeam: s.deleteTeam, deleteTeam: s.deleteTeam,
openTeamsTab: s.openTeamsTab, openTeamsTab: s.openTeamsTab,
closeTab: s.closeTab, closeTab: s.closeTab,
@ -1537,7 +1537,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onStartTask={(taskId) => { onStartTask={(taskId) => {
void (async () => { void (async () => {
try { try {
const result = await startTask(teamName, taskId); const result = await startTaskByUser(teamName, taskId);
if (data?.isAlive) { if (data?.isAlive) {
const task = data.tasks.find((t) => t.id === taskId); const task = data.tasks.find((t) => t.id === taskId);
try { try {

View file

@ -583,6 +583,7 @@ export interface TeamSlice {
) => Promise<void>; ) => Promise<void>;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>; updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
updateTaskFields: ( updateTaskFields: (
@ -1443,6 +1444,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return result; 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) => { updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
await unwrapIpc('team:updateTaskStatus', () => await unwrapIpc('team:updateTaskStatus', () =>
api.teams.updateTaskStatus(teamName, taskId, status) api.teams.updateTaskStatus(teamName, taskId, status)

View file

@ -81,6 +81,16 @@ export function extractAgentBlockContents(text: string): string[] {
*/ */
export const AGENT_BLOCK_REGEX = new RegExp(AGENT_BLOCK_PATTERN, 'g'); 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. * Fenced code block marker for reply messages between agents.
* *

View file

@ -443,6 +443,7 @@ export interface TeamsAPI {
fields: { subject?: string; description?: string } fields: { subject?: string; description?: string }
) => Promise<void>; ) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
processSend: (teamName: string, message: string) => Promise<void>; processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>; processAlive: (teamName: string) => Promise<boolean>;
aliveList: () => Promise<string[]>; aliveList: () => Promise<string[]>;