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:
parent
7324b5236d
commit
141d0e22d9
11 changed files with 127 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue