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
### 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)

View file

@ -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<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[]>> {
setCurrentMainOp('team:getAllTasks');
const startedAt = Date.now();

View file

@ -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,

View file

@ -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<void> {
}
}
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

View file

@ -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';

View file

@ -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<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 }> => {
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> => {
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,
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 {

View file

@ -583,6 +583,7 @@ export interface TeamSlice {
) => Promise<void>;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
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>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
updateTaskFields: (
@ -1443,6 +1444,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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)

View file

@ -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.
*

View file

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