feat: add task management enhancements with start task functionality

- Introduced TEAM_START_TASK IPC channel to facilitate starting tasks and notifying agents.
- Updated task creation to include an option for immediate start, enhancing user experience.
- Enhanced task notifications with detailed instructions for agents upon task assignment.
- Improved team member logs handling and metadata extraction for better task tracking.

These changes aim to streamline task management and improve team collaboration efficiency.
This commit is contained in:
iliya 2026-02-22 18:32:30 +02:00 committed by Илия
parent 3c1ef54ce2
commit bcda8b62cc
29 changed files with 1872 additions and 268 deletions

View file

@ -18,6 +18,7 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_START_TASK,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
@ -100,6 +101,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
ipcMain.handle(TEAM_START_TASK, handleStartTask);
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
logger.info('Team handlers registered');
}
@ -125,6 +127,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
ipcMain.removeHandler(TEAM_START_TASK);
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
}
@ -531,6 +534,9 @@ async function handleCreateTask(
return { success: false, error: 'prompt exceeds max length (5000)' };
}
}
if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') {
return { success: false, error: 'startImmediately must be a boolean' };
}
return wrapTeamHandler('createTask', () =>
getTeamDataService().createTask(validatedTeamName.value!, {
@ -539,6 +545,7 @@ async function handleCreateTask(
owner: payload.owner?.trim() || undefined,
blockedBy: payload.blockedBy,
prompt: payload.prompt?.trim() || undefined,
startImmediately: payload.startImmediately,
})
);
}
@ -762,6 +769,24 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
}
async function handleStartTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<void>> {
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('startTask', () =>
getTeamDataService().startTask(validatedTeamName.value!, validatedTaskId.value!)
);
}
async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
}

View file

@ -5,7 +5,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 3;
const TOOL_VERSION = 4;
function buildTeamCtlScript(): string {
const script = String.raw`#!/usr/bin/env node
@ -395,7 +395,7 @@ function printHelp() {
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
' node teamctl.js kanban clear <id> [--team <team>]',
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
@ -453,25 +453,26 @@ async function main() {
if (notify && task.owner) {
const from =
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user';
const text =
'New task assigned to you: #' +
String(task.id) +
' "' +
String(task.subject) +
'".\n\n' +
'When you start: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
String(teamName) +
' task start ' +
String(task.id) +
'\n' +
'When done: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
String(teamName) +
' task complete ' +
String(task.id) +
'\n';
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
if (rawDesc && rawDesc !== task.subject) {
parts.push('\nDescription:\n' + rawDesc);
}
const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : '';
if (prompt) {
parts.push('\nInstructions:\n' + prompt);
}
parts.push(
'\n${'```'}info_for_agent',
'Update task status using:',
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
'${'```'}'
);
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
text: parts.join('\n'),
summary: 'New task #' + String(task.id) + ' assigned',
from,
});

View file

@ -5,6 +5,7 @@ import {
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
@ -216,12 +217,14 @@ export class TeamDataService {
/* best-effort */
}
const shouldStart = request.owner && request.startImmediately !== false;
const task: TeamTask = {
id: nextId,
subject: request.subject,
description,
owner: request.owner,
status: request.owner ? 'in_progress' : 'pending',
status: shouldStart ? 'in_progress' : 'pending',
blocks: [],
blockedBy,
projectPath,
@ -234,18 +237,33 @@ export class TeamDataService {
await this.taskWriter.addBlocksEntry(teamName, depId, nextId);
}
if (request.owner) {
if (shouldStart && request.owner) {
try {
const toolPath = await this.toolsInstaller.ensureInstalled();
// Build notification with full context — inbox is the primary delivery
// channel to agents (Claude Code monitors inbox via fs.watch)
const parts = [`New task assigned to you: #${task.id} "${task.subject}".`];
if (request.description?.trim()) {
parts.push(`\nDescription:\n${request.description.trim()}`);
}
if (request.prompt?.trim()) {
parts.push(`\nInstructions:\n${request.prompt.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task start ${task.id}`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE
);
await this.sendMessage(teamName, {
member: request.owner,
text:
`New task assigned to you: #${task.id} "${task.subject}".\n\n` +
`Update task status using:\n` +
`node "${toolPath}" --team ${teamName} task start ${task.id}\n` +
`node "${toolPath}" --team ${teamName} task complete ${task.id}\n\n` +
`Help:\n` +
`node "${toolPath}" --help`,
text: parts.join('\n'),
summary: `New task #${task.id} assigned`,
});
} catch {
@ -256,6 +274,42 @@ export class TeamDataService {
return task;
}
async startTask(teamName: string, taskId: string): Promise<void> {
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})`);
}
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
if (task.owner) {
try {
const toolPath = await this.toolsInstaller.ensureInstalled();
const parts = [`Task #${task.id} "${task.subject}" has been started.`];
if (task.description?.trim()) {
parts.push(`\nDetails:\n${task.description.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE
);
await this.sendMessage(teamName, {
member: task.owner,
text: parts.join('\n'),
summary: `Task #${task.id} started`,
});
} catch {
// Best-effort notification
}
}
}
async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
await this.taskWriter.updateStatus(teamName, taskId, status);
}
@ -279,10 +333,12 @@ export class TeamDataService {
member: reviewer,
text:
`Please review task #${taskId}.\n\n` +
`${AGENT_BLOCK_OPEN}\n` +
`When approved, move it to APPROVED:\n` +
`node "${toolPath}" --team ${teamName} review approve ${taskId}\n\n` +
`If changes are needed:\n` +
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."`,
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` +
AGENT_BLOCK_CLOSE,
summary: `Review request for #${taskId}`,
});
} catch (error) {

View file

@ -1,5 +1,6 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
import { createReadStream } from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
@ -13,7 +14,18 @@ import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types';
const logger = createLogger('Service:TeamMemberLogsFinder');
const MAX_LINES_TO_SCAN = 30;
/**
* Phase 1: How many lines to scan for member attribution.
* Detection signals (process.team.memberName, "You are {name}", routing.sender)
* appear in the first ~10 lines, so 50 is very conservative.
*/
const ATTRIBUTION_SCAN_LINES = 50;
interface StreamedMetadata {
firstTimestamp: string | null;
lastTimestamp: string | null;
messageCount: number;
}
function trimTrailingSlashes(value: string): string {
let end = value.length;
@ -126,16 +138,11 @@ export class TeamMemberLogsFinder {
if (file.startsWith('agent-acompact')) continue;
const filePath = path.join(subagentsDir, file);
// Quick attribution check — reuse parseSubagentSummary to verify membership
const summary = await this.parseSubagentSummary(
filePath,
projectId,
sessionId,
file,
memberName,
knownMembers
);
if (summary) paths.push(filePath);
// Quick attribution check — only Phase 1 (no full-file streaming)
const attribution = await this.attributeSubagent(filePath, knownMembers);
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
paths.push(filePath);
}
}
}
@ -168,21 +175,35 @@ export class TeamMemberLogsFinder {
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
let sessionIds: string[];
// Collect all known session IDs: current lead + history
const knownSessionIds = new Set<string>();
if (config.leadSessionId) {
const leadDir = path.join(projectDir, config.leadSessionId);
try {
const stat = await fs.stat(leadDir);
if (stat.isDirectory()) {
sessionIds = [config.leadSessionId];
} else {
logger.debug(`leadSessionId dir is not a directory: ${leadDir}`);
sessionIds = await this.listSessionDirs(projectDir);
knownSessionIds.add(config.leadSessionId);
}
if (Array.isArray(config.sessionHistory)) {
for (const sid of config.sessionHistory) {
if (typeof sid === 'string' && sid.trim().length > 0) {
knownSessionIds.add(sid.trim());
}
} catch {
logger.debug(`leadSessionId dir not found: ${leadDir}, falling back to full scan`);
sessionIds = await this.listSessionDirs(projectDir);
}
}
let sessionIds: string[];
if (knownSessionIds.size > 0) {
// Verify each known session dir exists, fall back to full scan if none exist
const verified: string[] = [];
for (const sid of knownSessionIds) {
const sidDir = path.join(projectDir, sid);
try {
const stat = await fs.stat(sidDir);
if (stat.isDirectory()) {
verified.push(sid);
}
} catch {
// dir doesn't exist, skip
}
}
sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir);
} else {
sessionIds = await this.listSessionDirs(projectDir);
}
@ -237,112 +258,28 @@ export class TeamMemberLogsFinder {
knownMembers: Set<string>
): Promise<MemberSubagentLogSummary | null> {
const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, '');
const lines: string[] = [];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
// ── Phase 1: Attribution (first N lines) ──
// Detect which member owns this file + extract description.
// All detection signals appear in the first few lines of the JSONL.
const attribution = await this.attributeSubagent(filePath, knownMembers);
if (!attribution) return null;
let count = 0;
for await (const line of rl) {
if (count >= MAX_LINES_TO_SCAN) break;
const trimmed = line.trim();
if (trimmed) {
lines.push(trimmed);
count++;
}
}
rl.close();
stream.destroy();
} catch {
return null;
}
if (lines.length === 0) return null;
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
let messageCount = 0;
let description = '';
const targetLower = targetMember.toLowerCase();
// Multi-signal member detection with priority levels:
// 3 = routing sender (highest — directly identifies the agent)
// 2 = "You are {name}" spawn prompt (high — reliable identification)
// 1 = text-based fallback (low — may match wrong member from teammate_id etc.)
let detectedMember: string | null = null;
let detectionPriority = 0;
for (const line of lines) {
try {
const msg = JSON.parse(line) as Record<string, unknown>;
const role = this.extractRole(msg);
const textContent = this.extractTextContent(msg);
// Skip warmup messages
if (role === 'user' && textContent?.trim() === 'Warmup') {
return null;
}
// Track timestamps
if (typeof msg.timestamp === 'string') {
if (!firstTimestamp) firstTimestamp = msg.timestamp;
lastTimestamp = msg.timestamp;
}
messageCount++;
// Extract description from first user message
if (role === 'user' && !description && textContent) {
description = textContent.slice(0, 200);
}
// --- Multi-signal member detection ---
// Higher priority signals override lower priority ones
const detection = this.detectMemberFromMessage(msg, knownMembers);
if (detection && detection.priority > detectionPriority) {
detectedMember = detection.name;
detectionPriority = detection.priority;
}
// Check toolUseResult routing (highest priority — directly identifies the agent)
if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') {
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
| Record<string, unknown>
| undefined;
if (routing && typeof routing.sender === 'string') {
const sender = routing.sender.toLowerCase();
if (knownMembers.has(sender)) {
detectedMember = routing.sender;
detectionPriority = 3;
}
}
}
} catch {
// Skip malformed lines
}
}
// Match: the detected member must match the target member
if (detectedMember?.toLowerCase() !== targetLower) {
if (attribution.detectedMember.toLowerCase() !== targetLower) {
return null;
}
if (!firstTimestamp) {
// Fallback: use file mtime
try {
const stat = await fs.stat(filePath);
firstTimestamp = stat.mtime.toISOString();
lastTimestamp = firstTimestamp;
} catch {
firstTimestamp = new Date().toISOString();
lastTimestamp = firstTimestamp;
}
}
// ── Phase 2: Metadata (stream entire file) ──
// Now that we know the file belongs to this member, collect
// accurate timestamps and message count from the full file.
const metadata = await this.streamFileMetadata(filePath);
const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(filePath));
const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
const startTime = new Date(firstTimestamp);
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
const endTime = new Date(lastTimestamp);
const durationMs = endTime.getTime() - startTime.getTime();
// Check if the file might still be active (modified recently)
@ -360,15 +297,135 @@ export class TeamMemberLogsFinder {
subagentId,
sessionId,
projectId,
description: description || `Subagent ${subagentId}`,
description: attribution.description || `Subagent ${subagentId}`,
memberName: targetMember,
startTime: firstTimestamp,
durationMs: Math.max(0, durationMs),
messageCount,
messageCount: metadata.messageCount,
isOngoing,
};
}
/**
* Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals
* and extract a human-readable description from the first user message.
* Returns null if the file is a warmup session or empty.
*/
private async attributeSubagent(
filePath: string,
knownMembers: Set<string>
): Promise<{ detectedMember: string; description: string } | null> {
const lines: string[] = [];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let count = 0;
for await (const line of rl) {
if (count >= ATTRIBUTION_SCAN_LINES) break;
const trimmed = line.trim();
if (trimmed) {
lines.push(trimmed);
count++;
}
}
rl.close();
stream.destroy();
} catch {
return null;
}
if (lines.length === 0) return null;
let description = '';
let detectedMember: string | null = null;
let detectionPriority = 0;
for (const line of lines) {
// Early exit: both objectives met (member detected at max priority + description found)
if (detectionPriority >= 3 && description) break;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
const role = this.extractRole(msg);
const textContent = this.extractTextContent(msg);
// Skip warmup messages
if (role === 'user' && textContent?.trim() === 'Warmup') {
return null;
}
// Extract description from first user message + teammate_id attribution
if (role === 'user' && textContent) {
if (textContent.trimStart().startsWith('<teammate-message')) {
const parsed = parseAllTeammateMessages(textContent);
if (!description) {
description =
parsed[0]?.summary || parsed[0]?.content?.slice(0, 200) || 'Teammate spawn';
}
// teammate_id is a structured XML attribute — highest reliability signal
if (detectionPriority < 3 && parsed[0]?.teammateId) {
const tmId = parsed[0].teammateId.trim().toLowerCase();
if (tmId.length > 0 && knownMembers.has(tmId)) {
detectedMember = parsed[0].teammateId.trim();
detectionPriority = 3;
}
}
} else if (!description) {
description = textContent.slice(0, 200);
}
}
// --- Multi-signal member detection ---
// Higher priority signals override lower priority ones (skip if already at max)
if (detectionPriority < 3) {
const detection = this.detectMemberFromMessage(msg, knownMembers);
if (detection && detection.priority > detectionPriority) {
detectedMember = detection.name;
detectionPriority = detection.priority;
}
}
// Check toolUseResult routing (highest priority — directly identifies the agent)
if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') {
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
| Record<string, unknown>
| undefined;
if (routing && typeof routing.sender === 'string') {
const sender = routing.sender.toLowerCase();
if (knownMembers.has(sender)) {
detectedMember = routing.sender;
detectionPriority = 3;
}
}
}
// Check process.team.memberName from system messages (highest priority)
if (detectionPriority < 3) {
const init = msg.init as Record<string, unknown> | undefined;
const process = (msg.process ?? init?.process) as Record<string, unknown> | undefined;
const team = process?.team as Record<string, unknown> | undefined;
if (team && typeof team.memberName === 'string') {
const memberNameLower = team.memberName.trim().toLowerCase();
if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) {
detectedMember = team.memberName.trim();
detectionPriority = 3;
}
}
}
} catch {
// Skip malformed lines
}
}
if (!detectedMember) return null;
return { detectedMember, description };
}
/**
* Detects the member name from a parsed JSONL message using multiple signals.
* Returns a detection result with the name and a priority level:
@ -463,49 +520,13 @@ export class TeamMemberLogsFinder {
return null;
}
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
let messageCount = 0;
const metadata = await this.streamFileMetadata(jsonlPath);
try {
const stream = createReadStream(jsonlPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let count = 0;
for await (const line of rl) {
if (count >= MAX_LINES_TO_SCAN) break;
const trimmed = line.trim();
if (!trimmed) continue;
count++;
messageCount++;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof msg.timestamp === 'string') {
if (!firstTimestamp) firstTimestamp = msg.timestamp;
lastTimestamp = msg.timestamp;
}
} catch {
// ignore
}
}
rl.close();
stream.destroy();
} catch {
// ignore
}
if (!firstTimestamp) {
try {
const stat = await fs.stat(jsonlPath);
firstTimestamp = stat.mtime.toISOString();
lastTimestamp = firstTimestamp;
} catch {
firstTimestamp = new Date().toISOString();
lastTimestamp = firstTimestamp;
}
}
const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(jsonlPath));
const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
const startTime = new Date(firstTimestamp);
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
const endTime = new Date(lastTimestamp);
const durationMs = endTime.getTime() - startTime.getTime();
let isOngoing = false;
@ -525,10 +546,55 @@ export class TeamMemberLogsFinder {
memberName,
startTime: firstTimestamp,
durationMs: Math.max(0, durationMs),
messageCount,
messageCount: metadata.messageCount,
isOngoing,
};
}
/**
* Stream entire JSONL file collecting only timestamps and message count.
* Lightweight uses regex to extract timestamp without full JSON parse.
*/
private async streamFileMetadata(filePath: string): Promise<StreamedMetadata> {
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
let messageCount = 0;
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
messageCount++;
// Fast timestamp extraction without full JSON parse.
// ISO prefix anchor avoids false positives from "timestamp" inside string values.
const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(trimmed);
if (tsMatch) {
if (!firstTimestamp) firstTimestamp = tsMatch[1];
lastTimestamp = tsMatch[1];
}
}
rl.close();
stream.destroy();
} catch {
// ignore — return whatever we collected so far
}
return { firstTimestamp, lastTimestamp, messageCount };
}
private async getFileMtime(filePath: string): Promise<string> {
try {
const stat = await fs.stat(filePath);
return stat.mtime.toISOString();
} catch {
return new Date().toISOString();
}
}
}
function findOriginalCase(text: string, lowerName: string): string {

View file

@ -242,5 +242,8 @@ export const TEAM_UPDATE_CONFIG = 'team:updateConfig';
/** Get aggregated member stats */
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';
/** Get all tasks across all teams */
export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';

View file

@ -38,6 +38,7 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_START_TASK,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
@ -205,8 +206,7 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
// Agent config reading
readAgentConfigs: (projectRoot: string) =>
ipcRenderer.invoke('read-agent-configs', projectRoot),
readAgentConfigs: (projectRoot: string) => ipcRenderer.invoke('read-agent-configs', projectRoot),
// Notifications API
notifications: {
@ -540,6 +540,9 @@ const electronAPI: ElectronAPI = {
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
},
startTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
},
processSend: async (teamName: string, message: string) => {
return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message);
},

View file

@ -668,6 +668,9 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
throw new Error('Team task status update is not available in browser mode');
},
startTask: async (_teamName: string, _taskId: string): Promise<void> => {
throw new Error('Team start task 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

@ -78,12 +78,14 @@ export const GlobalTaskList = (): React.JSX.Element => {
const [filter, setFilter] = useState<StatusFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const hasFetchedRef = useRef(false);
useEffect(() => {
if (globalTasks.length === 0 && !globalTasksLoading) {
if (!hasFetchedRef.current) {
hasFetchedRef.current = true;
void fetchAllTasks();
}
}, [globalTasks.length, globalTasksLoading, fetchAllTasks]);
}, [fetchAllTasks]);
const selectedProjectPath = useMemo(() => {
if (viewMode === 'grouped') {

View file

@ -14,6 +14,7 @@ import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
import { ReviewDialog } from './dialogs/ReviewDialog';
import { SendMessageDialog } from './dialogs/SendMessageDialog';
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { MemberDetailDialog } from './members/MemberDetailDialog';
@ -58,6 +59,7 @@ function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] {
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false,
@ -91,6 +93,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendTeamMessage,
requestReview,
createTeamTask,
startTask,
deleteTeam,
openTeamsTab,
sendingMessage,
@ -113,6 +116,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendTeamMessage: s.sendTeamMessage,
requestReview: s.requestReview,
createTeamTask: s.createTeamTask,
startTask: s.startTask,
deleteTeam: s.deleteTeam,
openTeamsTab: s.openTeamsTab,
sendingMessage: s.sendingMessage,
@ -257,6 +261,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return filterKanbanTasks(filteredTasks, query);
}, [filteredTasks, kanbanSearch]);
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
setCreateTaskDialog({
open: true,
@ -297,7 +303,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
description: string,
owner?: string,
blockedBy?: string[],
prompt?: string
prompt?: string,
startImmediately?: boolean
): void => {
setCreatingTask(true);
void (async () => {
@ -308,9 +315,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
owner,
blockedBy,
prompt,
startImmediately,
});
if (prompt && owner && data?.isAlive) {
if (prompt && owner && data?.isAlive && startImmediately !== false) {
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
try {
await api.teams.processSend(teamName, msg);
@ -534,6 +542,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onMoveBackToDone={(taskId) => {
void updateKanban(teamName, taskId, { op: 'remove' });
}}
onStartTask={(taskId) => {
void (async () => {
try {
await startTask(teamName, taskId);
if (data?.isAlive) {
const task = data.tasks.find((t) => t.id === taskId);
if (task?.owner) {
try {
await api.teams.processSend(
teamName,
`Task #${taskId} "${task.subject}" has started. Please begin working on it.`
);
} catch {
// best-effort
}
}
}
} catch {
// error via store
}
})();
}}
onCompleteTask={(taskId) => {
void updateTaskStatus(teamName, taskId, 'completed');
}}
@ -545,6 +575,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
}
}}
onTaskClick={(task) => setSelectedTask(task)}
/>
</CollapsibleTeamSection>
@ -664,6 +695,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}}
onClose={() => setSendDialogOpen(false)}
/>
<TaskDetailDialog
open={selectedTask !== null}
task={selectedTask}
teamName={teamName}
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
taskMap={taskMap}
onClose={() => setSelectedTask(null)}
onScrollToTask={(taskId) => {
setSelectedTask(null);
const el = document.querySelector(`[data-task-id="${taskId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
el.classList.add('ring-2', 'ring-blue-400/50');
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
}
}}
/>
</div>
);
};

View file

@ -1,3 +1,5 @@
import { useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import {
CARD_BG,
@ -12,7 +14,8 @@ import {
parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { Bot, ListPlus, MessageSquare } from 'lucide-react';
import { AGENT_BLOCK_REGEX } from '@shared/constants/agentBlocks';
import { Bot, ChevronRight, ListPlus, MessageSquare } from 'lucide-react';
import type { TeamColorSet } from '@renderer/constants/teamColors';
import type { InboxMessage } from '@shared/types';
@ -86,7 +89,31 @@ const NoiseRow = ({
);
// ---------------------------------------------------------------------------
// Full message card — left colored border, name badge, expanded content
// Detect system/automated messages that should be collapsed by default.
// These are generated by teamctl.js and contain tool instructions, not
// human-written content, so showing them expanded adds visual noise.
// ---------------------------------------------------------------------------
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
{ pattern: /^New task assigned to you:/, label: 'Task assignment' },
{ pattern: /^Task #\d+\s+approved/, label: 'Task approved' },
{ pattern: /^Task #\d+\s+needs fixes/, label: 'Review changes requested' },
];
function getSystemMessageLabel(text: string): string | null {
for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) {
if (pattern.test(text)) return label;
}
return null;
}
/** Strip ```info_for_agent ... ``` blocks from text for UI display. */
function stripAgentBlocks(text: string): string {
return text.replace(AGENT_BLOCK_REGEX, '').trim();
}
// ---------------------------------------------------------------------------
// Full message card — left colored border, name badge, collapsible content
// ---------------------------------------------------------------------------
export const ActivityItem = ({
@ -105,6 +132,16 @@ export const ActivityItem = ({
const structured = parseStructuredAgentMessage(message.text);
const noiseLabel = structured ? getNoiseLabel(structured) : null;
// System/automated messages start collapsed
const systemLabel = !structured ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text
const displayText = useMemo(
() => (structured ? null : stripAgentBlocks(message.text)),
[structured, message.text]
);
// Noise messages: minimal inline row
if (noiseLabel) {
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
@ -132,8 +169,37 @@ export const ActivityItem = ({
borderLeft: `3px solid ${colors.border}`,
}}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
{/* Header — clickable when system message to toggle expand */}
<div
className={[
'flex items-center gap-2 px-3 py-2',
systemLabel ? 'cursor-pointer select-none' : '',
].join(' ')}
role={systemLabel ? 'button' : undefined}
tabIndex={systemLabel ? 0 : undefined}
onClick={systemLabel ? () => setIsExpanded((v) => !v) : undefined}
onKeyDown={
systemLabel
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded((v) => !v);
}
}
: undefined
}
>
{/* Chevron for collapsible system messages */}
{systemLabel ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: isExpanded ? 'rotate(90deg)' : undefined,
}}
/>
) : null}
{message.source === 'lead_session' ? (
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
) : (
@ -159,8 +225,12 @@ export const ActivityItem = ({
</span>
) : null}
{/* Message type label */}
{messageType ? (
{/* Message type label or system label */}
{systemLabel ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{systemLabel}
</span>
) : messageType ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{messageType}
</span>
@ -193,7 +263,10 @@ export const ActivityItem = ({
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
title="Create task from message"
onClick={handleCreateTask}
onClick={(e) => {
e.stopPropagation();
handleCreateTask();
}}
>
<ListPlus size={14} />
</button>
@ -204,26 +277,28 @@ export const ActivityItem = ({
</div>
</div>
{/* Content — always expanded */}
<div className="px-3 pb-3">
{structured ? (
<div className="space-y-2">
{autoSummary && autoSummary !== messageType ? (
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
) : null}
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
Raw JSON
</summary>
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{JSON.stringify(structured, null, 2)}
</pre>
</details>
</div>
) : (
<MarkdownViewer content={message.text} maxHeight="max-h-56" copyable />
)}
</div>
{/* Content — collapsed for system messages, expanded for others */}
{isExpanded ? (
<div className="px-3 pb-3">
{structured ? (
<div className="space-y-2">
{autoSummary && autoSummary !== messageType ? (
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
) : null}
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
Raw JSON
</summary>
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{JSON.stringify(structured, null, 2)}
</pre>
</details>
</div>
) : (
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable />
)}
</div>
) : null}
</article>
);
};

View file

@ -1,8 +1,8 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
Dialog,
DialogContent,
@ -13,6 +13,7 @@ import {
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
Select,
SelectContent,
@ -20,9 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface CreateTaskDialogProps {
@ -38,7 +41,8 @@ interface CreateTaskDialogProps {
description: string,
owner?: string,
blockedBy?: string[],
prompt?: string
prompt?: string,
startImmediately?: boolean
) => void;
submitting?: boolean;
}
@ -61,6 +65,7 @@ export const CreateTaskDialog = ({
});
const [owner, setOwner] = useState<string>(defaultOwner);
const [blockedBy, setBlockedBy] = useState<string[]>([]);
const [startImmediately, setStartImmediately] = useState(true);
const promptDraft = useDraftPersistence({ key: 'createTask:prompt' });
const [prevOpen, setPrevOpen] = useState(false);
@ -71,12 +76,24 @@ export const CreateTaskDialog = ({
}
setOwner(defaultOwner);
setBlockedBy([]);
setStartImmediately(true);
promptDraft.clearDraft();
}
if (open !== prevOpen) {
setPrevOpen(open);
}
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
})),
[members]
);
const canSubmit = subject.trim().length > 0 && !submitting;
// Only show non-internal, non-deleted tasks as candidates for blocking
@ -95,7 +112,8 @@ export const CreateTaskDialog = ({
descriptionDraft.value.trim(),
owner || undefined,
blockedBy.length > 0 ? blockedBy : undefined,
promptDraft.value.trim() || undefined
promptDraft.value.trim() || undefined,
startImmediately
);
descriptionDraft.clearDraft();
promptDraft.clearDraft();
@ -135,32 +153,38 @@ export const CreateTaskDialog = ({
<div className="grid gap-2">
<Label htmlFor="task-description">Description (optional)</Label>
<AutoResizeTextarea
<MentionableTextarea
id="task-description"
placeholder="Task details..."
value={descriptionDraft.value}
onValueChange={descriptionDraft.setValue}
suggestions={mentionSuggestions}
minRows={3}
maxRows={12}
onChange={(e) => descriptionDraft.setValue(e.target.value)}
footerRight={
descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label>
<AutoResizeTextarea
<MentionableTextarea
id="task-prompt"
placeholder="Custom instructions for the team member..."
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
minRows={3}
maxRows={12}
onChange={(e) => promptDraft.setValue(e.target.value)}
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
{promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="grid gap-2">
@ -175,11 +199,24 @@ export const CreateTaskDialog = ({
<SelectContent>
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.agentType);
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<SelectItem key={m.name} value={m.name}>
{m.name}
{role ? ` (${role})` : ''}
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? (
<span className="text-[var(--color-text-muted)]">({role})</span>
) : null}
</span>
</SelectItem>
);
})}
@ -187,6 +224,19 @@ export const CreateTaskDialog = ({
</Select>
</div>
{owner ? (
<div className="flex items-center gap-2">
<Checkbox
id="task-start-immediately"
checked={startImmediately}
onCheckedChange={(v) => setStartImmediately(v === true)}
/>
<Label htmlFor="task-start-immediately" className="text-xs font-normal">
Start immediately
</Label>
</div>
) : null}
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label>Blocked by tasks (optional)</Label>

View file

@ -15,6 +15,7 @@ import {
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
Select,
SelectContent,
@ -39,6 +40,7 @@ const TEAM_COLOR_NAMES = [
'pink',
] as const;
import type { MentionSuggestion } from '@renderer/types/mention';
import type {
Project,
TeamCreateRequest,
@ -426,6 +428,24 @@ export const CreateTeamDialog = ({
const description = descriptionDraft.value;
const prompt = promptDraft.value;
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members
.filter((m) => m.name.trim())
.map((m, index) => ({
id: m.id,
name: m.name.trim(),
subtitle:
m.roleSelection === CUSTOM_ROLE
? m.customRole.trim() || undefined
: m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection
: undefined,
color: getMemberColor(index),
})),
[members]
);
const request = useMemo<TeamCreateRequest>(
() => ({
teamName: teamName.trim(),
@ -744,18 +764,21 @@ export const CreateTeamDialog = ({
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt for team lead (optional)
</Label>
<AutoResizeTextarea
<MentionableTextarea
id="team-prompt"
className="text-xs"
minRows={3}
maxRows={12}
value={prompt}
onChange={(event) => promptDraft.setValue(event.target.value)}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
placeholder="Instructions for the team lead during provisioning..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
{promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
) : null}

View file

@ -1,6 +1,5 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -12,6 +11,7 @@ import {
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
Select,
SelectContent,
@ -19,9 +19,11 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
interface SendMessageDialogProps {
@ -72,6 +74,17 @@ export const SendMessageDialog = ({
onClose();
}
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
})),
[members]
);
const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
const handleSubmit = (): void => {
@ -107,11 +120,24 @@ export const SendMessageDialog = ({
<SelectContent>
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.agentType);
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<SelectItem key={m.name} value={m.name}>
{m.name}
{role ? ` (${role})` : ''}
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? (
<span className="text-[var(--color-text-muted)]">({role})</span>
) : null}
</span>
</SelectItem>
);
})}
@ -131,17 +157,20 @@ export const SendMessageDialog = ({
<div className="grid gap-2">
<Label htmlFor="smd-message">Message</Label>
<AutoResizeTextarea
<MentionableTextarea
id="smd-message"
placeholder="Write your message..."
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
minRows={4}
maxRows={12}
onChange={(e) => textDraft.setValue(e.target.value)}
footerRight={
textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
{textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}

View file

@ -0,0 +1,214 @@
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReviewBadge } from '@renderer/components/team/kanban/ReviewBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns';
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, FileText, User } from 'lucide-react';
import type { KanbanTaskState, TeamTask } from '@shared/types';
interface TaskDetailDialogProps {
open: boolean;
task: TeamTask | null;
teamName: string;
kanbanTaskState?: KanbanTaskState;
taskMap: Map<string, TeamTask>;
onClose: () => void;
onScrollToTask?: (taskId: string) => void;
}
export const TaskDetailDialog = ({
open,
task,
teamName,
kanbanTaskState,
taskMap,
onClose,
onScrollToTask,
}: TaskDetailDialogProps): React.JSX.Element => {
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
if (!currentTask) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Task not found</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
const status = currentTask.status;
const statusStyle = TASK_STATUS_STYLES[status];
const statusLabel = TASK_STATUS_LABELS[status];
const blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = currentTask.blocks?.filter((id) => id.length > 0) ?? [];
const handleDependencyClick = (taskId: string): void => {
onClose();
onScrollToTask?.(taskId);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
#{currentTask.id}
</Badge>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
>
{statusLabel}
</span>
</div>
<DialogTitle className="text-base">{currentTask.subject}</DialogTitle>
{currentTask.activeForm ? (
<DialogDescription>{currentTask.activeForm}</DialogDescription>
) : null}
</DialogHeader>
{/* Metadata */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
<User size={12} />
<span className="text-[var(--color-text-secondary)]">
{currentTask.owner ?? '\u2014'}
</span>
</div>
{currentTask.createdAt ? (
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
<Clock size={12} />
<span className="text-[var(--color-text-secondary)]">
{formatDistanceToNow(new Date(currentTask.createdAt), { addSuffix: true })}
</span>
</div>
) : null}
</div>
{/* Description */}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
<FileText size={12} />
Description
</div>
{currentTask.description ? (
<div className="max-h-[200px] overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
</div>
) : (
<p className="text-xs text-[var(--color-text-muted)]">No description</p>
)}
</div>
{/* Dependencies */}
{blockedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
<ArrowLeftFromLine size={12} />
Blocked by
</span>
{blockedByIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
return (
<button
key={id}
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} cursor-pointer`}
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
onClick={() => handleDependencyClick(id)}
>
#{id}
</button>
);
})}
</div>
) : null}
{blocksIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
<ArrowRightFromLine size={12} />
Blocks
</span>
{blocksIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
return (
<button
key={id}
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
} cursor-pointer`}
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
onClick={() => handleDependencyClick(id)}
>
#{id}
</button>
);
})}
</div>
) : null}
{/* Review info */}
{kanbanTaskState ? (
<div className="flex items-center gap-2">
<ReviewBadge status={kanbanTaskState.reviewStatus} />
{kanbanTaskState.reviewer ? (
<span className="text-xs text-[var(--color-text-secondary)]">
Reviewer: {kanbanTaskState.reviewer}
</span>
) : null}
{kanbanTaskState.errorDescription ? (
<span className="text-xs text-red-400">{kanbanTaskState.errorDescription}</span>
) : null}
</div>
) : null}
{/* Separator */}
<div className="border-t border-[var(--color-border)]" />
{/* Session Logs */}
<div>
<h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]">
Execution Logs
</h4>
{currentTask.owner ? (
<MemberLogsTab teamName={teamName} memberName={currentTask.owner} />
) : (
<p className="py-6 text-center text-xs text-[var(--color-text-muted)]">
Assign a member to see execution logs
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -24,8 +24,10 @@ interface KanbanBoardProps {
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
onMoveBackToDone: (taskId: string) => void;
onStartTask: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
}
type KanbanViewMode = 'grid' | 'columns';
@ -68,8 +70,10 @@ export const KanbanBoard = ({
onApprove,
onRequestChanges,
onMoveBackToDone,
onStartTask,
onCompleteTask,
onScrollToTask,
onTaskClick,
}: KanbanBoardProps): React.JSX.Element => {
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
@ -110,8 +114,10 @@ export const KanbanBoard = ({
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
/>
))}
</>

View file

@ -1,6 +1,6 @@
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2 } from 'lucide-react';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
import { ReviewBadge } from './ReviewBadge';
@ -16,8 +16,10 @@ interface KanbanTaskCardProps {
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
onMoveBackToDone: (taskId: string) => void;
onStartTask: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
}
interface DependencyBadgeProps {
@ -63,8 +65,10 @@ export const KanbanTaskCard = ({
onApprove,
onRequestChanges,
onMoveBackToDone,
onStartTask,
onCompleteTask,
onScrollToTask,
onTaskClick,
}: KanbanTaskCardProps): React.JSX.Element => {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
@ -72,13 +76,22 @@ export const KanbanTaskCard = ({
const hasBlocks = blocksIds.length > 0;
return (
<article
<div
data-task-id={task.id}
className={`rounded-md border p-3 ${
className={`cursor-pointer rounded-md border p-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
hasBlockedBy
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
}`}
role="button"
tabIndex={0}
onClick={() => onTaskClick?.(task)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTaskClick?.(task);
}
}}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div>
@ -126,13 +139,47 @@ export const KanbanTaskCard = ({
</div>
) : null}
{columnId === 'todo' || columnId === 'in_progress' ? (
{columnId === 'todo' ? (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Start task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onStartTask(task.id);
}}
>
<Play size={12} />
Start
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
>
<CheckCircle2 size={12} />
Complete
</Button>
</div>
) : null}
{columnId === 'in_progress' ? (
<Button
variant="outline"
size="sm"
className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={() => onCompleteTask(task.id)}
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
>
<CheckCircle2 size={12} />
Complete
@ -144,7 +191,10 @@ export const KanbanTaskCard = ({
variant="outline"
size="sm"
aria-label={`Request review for task ${task.id}`}
onClick={() => onRequestReview(task.id)}
onClick={(e) => {
e.stopPropagation();
onRequestReview(task.id);
}}
>
Request Review
</Button>
@ -160,7 +210,10 @@ export const KanbanTaskCard = ({
variant="outline"
size="sm"
aria-label={`Approve task ${task.id}`}
onClick={() => onApprove(task.id)}
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
>
Approve
</Button>
@ -168,7 +221,10 @@ export const KanbanTaskCard = ({
variant="destructive"
size="sm"
aria-label={`Request changes for task ${task.id}`}
onClick={() => onRequestChanges(task.id)}
onClick={(e) => {
e.stopPropagation();
onRequestChanges(task.id);
}}
>
Request Changes
</Button>
@ -181,11 +237,14 @@ export const KanbanTaskCard = ({
variant="outline"
size="sm"
aria-label={`Move task ${task.id} back to done`}
onClick={() => onMoveBackToDone(task.id)}
onClick={(e) => {
e.stopPropagation();
onMoveBackToDone(task.id);
}}
>
Move back to DONE
</Button>
) : null}
</article>
</div>
);
};

View file

@ -0,0 +1,99 @@
import { useEffect, useRef } from 'react';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import type { MentionSuggestion } from '@renderer/types/mention';
interface MentionSuggestionListProps {
suggestions: MentionSuggestion[];
selectedIndex: number;
onSelect: (s: MentionSuggestion) => void;
query: string;
}
const HighlightedName = ({ name, query }: { name: string; query: string }): React.JSX.Element => {
if (!query) return <span>{name}</span>;
const lower = name.toLowerCase();
const qLower = query.toLowerCase();
const idx = lower.indexOf(qLower);
if (idx < 0) return <span>{name}</span>;
const before = name.slice(0, idx);
const match = name.slice(idx, idx + query.length);
const after = name.slice(idx + query.length);
return (
<span>
{before}
<span className="bg-[var(--color-accent)]/25 rounded text-[var(--color-text)]">{match}</span>
{after}
</span>
);
};
export const MentionSuggestionList = ({
suggestions,
selectedIndex,
onSelect,
query,
}: MentionSuggestionListProps): React.JSX.Element => {
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
const list = listRef.current;
if (!list) return;
const selected = list.children[selectedIndex] as HTMLElement | undefined;
selected?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (suggestions.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]">
No matching members
</div>
);
}
return (
<ul
ref={listRef}
role="listbox"
className="max-h-40 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1"
>
{suggestions.map((s, i) => {
const colorSet = s.color ? getTeamColorSet(s.color) : null;
const isSelected = i === selectedIndex;
return (
<li
key={s.id}
role="option"
aria-selected={isSelected}
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs transition-colors ${
isSelected
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onMouseDown={(e) => {
e.preventDefault();
onSelect(s);
}}
>
<span
className="inline-block size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: colorSet?.border ?? 'var(--color-text-muted)' }}
/>
<span className="font-medium" style={colorSet ? { color: colorSet.text } : undefined}>
<HighlightedName name={s.name} query={query} />
</span>
{s.subtitle ? (
<span className="text-[var(--color-text-muted)]">{s.subtitle}</span>
) : null}
</li>
);
})}
</ul>
);
};

View file

@ -0,0 +1,275 @@
import * as React from 'react';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
import { AutoResizeTextarea } from './auto-resize-textarea';
import { MentionSuggestionList } from './MentionSuggestionList';
import type { AutoResizeTextareaProps } from './auto-resize-textarea';
import type { MentionSuggestion } from '@renderer/types/mention';
// ---------------------------------------------------------------------------
// Mention segment parsing (splits text into plain text + @mention segments)
// ---------------------------------------------------------------------------
interface TextSegment {
type: 'text';
value: string;
}
interface MentionSegment {
type: 'mention';
value: string;
suggestion: MentionSuggestion;
}
type Segment = TextSegment | MentionSegment;
/**
* Splits text into alternating text / @mention segments.
*
* Rules:
* - `@` must be at start of text or preceded by whitespace
* - The name after `@` must exactly match a suggestion name (case-insensitive)
* - The character after the name must be whitespace, punctuation, or end-of-text
* - Longer names are tried first (greedy matching)
*/
function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): Segment[] {
if (!text || suggestions.length === 0) return [{ type: 'text', value: text }];
// Sort by name length descending for greedy matching
const sorted = [...suggestions].sort((a, b) => b.name.length - a.name.length);
const segments: Segment[] = [];
let i = 0;
let textStart = 0;
while (i < text.length) {
if (text[i] !== '@') {
i++;
continue;
}
// @ must be at start or after whitespace
if (i > 0) {
const ch = text[i - 1];
if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') {
i++;
continue;
}
}
let matched = false;
for (const suggestion of sorted) {
const end = i + 1 + suggestion.name.length;
if (end > text.length) continue;
if (text.slice(i + 1, end).toLowerCase() !== suggestion.name.toLowerCase()) continue;
// Character after name must be boundary
if (end < text.length) {
const after = text[end];
// eslint-disable-next-line no-useless-escape
if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue;
}
// Flush preceding text
if (i > textStart) {
segments.push({ type: 'text', value: text.slice(textStart, i) });
}
segments.push({ type: 'mention', value: text.slice(i, end), suggestion });
i = end;
textStart = i;
matched = true;
break;
}
if (!matched) i++;
}
if (textStart < text.length) {
segments.push({ type: 'text', value: text.slice(textStart) });
}
return segments;
}
// Default fallback color for mentions without a team color
const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)';
const DEFAULT_MENTION_TEXT = '#60a5fa';
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface MentionableTextareaProps extends Omit<
AutoResizeTextareaProps,
'value' | 'onChange' | 'onKeyDown' | 'onSelect'
> {
value: string;
onValueChange: (v: string) => void;
suggestions: MentionSuggestion[];
hintText?: string;
showHint?: boolean;
/** Content rendered at the right side of the footer row (e.g. "Draft saved") */
footerRight?: React.ReactNode;
}
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
(
{
value,
onValueChange,
suggestions,
hintText = 'Use @ to mention team members',
showHint = true,
footerRight,
style,
...textareaProps
},
forwardedRef
) => {
const internalRef = React.useRef<HTMLTextAreaElement | null>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const setRefs = React.useCallback(
(node: HTMLTextAreaElement | null) => {
internalRef.current = node;
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (forwardedRef) {
// eslint-disable-next-line no-param-reassign -- ref merging requires mutation
forwardedRef.current = node;
}
},
[forwardedRef]
);
const {
isOpen,
query,
filteredSuggestions,
selectedIndex,
dropdownPosition,
selectSuggestion,
handleKeyDown,
handleChange,
handleSelect,
} = useMentionDetection({
suggestions,
value,
onValueChange,
textareaRef: internalRef,
});
// --- Mention overlay ---
const hasMentionOverlay = suggestions.length > 0;
const segments = React.useMemo(
() => (hasMentionOverlay ? parseMentionSegments(value, suggestions) : []),
[hasMentionOverlay, value, suggestions]
);
// Sync backdrop scroll with textarea scroll
const handleScroll = React.useCallback(() => {
const textarea = internalRef.current;
const backdrop = backdropRef.current;
if (textarea && backdrop) {
backdrop.scrollTop = textarea.scrollTop;
}
}, []);
// When overlay is active: textarea text is transparent, caret stays visible
const textareaStyle: React.CSSProperties | undefined = hasMentionOverlay
? {
...style,
color: 'transparent',
caretColor: 'var(--color-text)',
position: 'relative' as const,
zIndex: 10,
background: 'transparent',
}
: style;
const showFooter = (showHint && suggestions.length > 0) || footerRight;
return (
<div className="relative">
{/* Inner wrapper for textarea + backdrop overlay */}
<div className="relative">
{hasMentionOverlay ? (
<div
ref={backdropRef}
className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm text-[var(--color-text)]"
style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
overflowWrap: 'break-word',
}}
aria-hidden="true"
>
{segments.map((seg, idx) => {
if (seg.type === 'text') {
return <React.Fragment key={idx}>{seg.value}</React.Fragment>;
}
const colorSet = seg.suggestion.color
? getTeamColorSet(seg.suggestion.color)
: null;
const bg = colorSet?.badge ?? DEFAULT_MENTION_BG;
const fg = colorSet?.text ?? DEFAULT_MENTION_TEXT;
return (
<span
key={idx}
style={{
backgroundColor: bg,
color: fg,
borderRadius: '3px',
boxShadow: `0 0 0 1.5px ${bg}`,
}}
>
{seg.value}
</span>
);
})}
{/* Trailing space ensures trailing newlines render correctly */}{' '}
</div>
) : null}
<AutoResizeTextarea
ref={setRefs}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
{...textareaProps}
onScroll={handleScroll}
style={textareaStyle}
/>
</div>
{showFooter ? (
<div className="mt-1 flex items-center justify-between">
{showHint && suggestions.length > 0 ? (
<span className="text-[10px] text-[var(--color-text-muted)]">{hintText}</span>
) : (
<span />
)}
{footerRight}
</div>
) : null}
{isOpen && dropdownPosition ? (
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
<MentionSuggestionList
suggestions={filteredSuggestions}
selectedIndex={selectedIndex}
onSelect={selectSuggestion}
query={query}
/>
</div>
) : null}
</div>
);
}
);
MentionableTextarea.displayName = 'MentionableTextarea';

View file

@ -83,6 +83,7 @@ export const Combobox = ({
<CommandPrimitive.List
id={listboxId}
className="max-h-72 overflow-y-auto overscroll-contain p-1"
onWheel={(e) => e.stopPropagation()}
>
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
{emptyMessage}

View file

@ -0,0 +1,298 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { MentionSuggestion } from '@renderer/types/mention';
interface UseMentionDetectionOptions {
suggestions: MentionSuggestion[];
value: string;
onValueChange: (v: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
}
export interface DropdownPosition {
top: number;
left: number;
}
interface UseMentionDetectionResult {
isOpen: boolean;
query: string;
filteredSuggestions: MentionSuggestion[];
selectedIndex: number;
dropdownPosition: DropdownPosition | null;
selectSuggestion: (s: MentionSuggestion) => void;
dismiss: () => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
}
interface MentionTrigger {
triggerIndex: number;
query: string;
}
/**
* CSS properties to copy from textarea to mirror div for accurate caret measurement.
*/
const MIRROR_PROPS = [
'boxSizing',
'width',
'overflowX',
'overflowY',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'letterSpacing',
'wordSpacing',
] as const;
/**
* Calculates caret coordinates relative to the textarea element
* using a mirror div technique.
*
* @param textarea - The textarea DOM element
* @param position - Caret position in text
* @param text - Text content (override textarea.value for pre-render accuracy)
*/
export function getCaretCoordinates(
textarea: HTMLTextAreaElement,
position: number,
text?: string
): { top: number; left: number; height: number } {
const content = text ?? textarea.value;
const computed = window.getComputedStyle(textarea);
const mirror = document.createElement('div');
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordWrap = 'break-word';
mirror.style.overflow = 'hidden';
for (const prop of MIRROR_PROPS) {
mirror.style.setProperty(prop, computed.getPropertyValue(prop));
}
mirror.textContent = content.substring(0, position);
const span = document.createElement('span');
span.textContent = content.substring(position) || '.';
mirror.appendChild(span);
document.body.appendChild(mirror);
const lineHeight = parseInt(computed.lineHeight) || parseInt(computed.fontSize) * 1.2;
const borderTop = parseInt(computed.borderTopWidth) || 0;
const coords = {
top: span.offsetTop + borderTop - textarea.scrollTop,
left: span.offsetLeft + (parseInt(computed.borderLeftWidth) || 0) - textarea.scrollLeft,
height: lineHeight,
};
document.body.removeChild(mirror);
return coords;
}
/**
* Scans backwards from cursor position to find an @ trigger.
* Returns null if no valid trigger found.
*
* Rules:
* - @ must be at start of text or preceded by whitespace
* - Text between @ and cursor must not contain spaces
*/
export function findMentionTrigger(text: string, cursorPos: number): MentionTrigger | null {
if (cursorPos <= 0) return null;
const beforeCursor = text.slice(0, cursorPos);
// Scan backwards to find @
for (let i = beforeCursor.length - 1; i >= 0; i--) {
const char = beforeCursor[i];
// If we hit a space before finding @, no valid trigger
if (char === ' ' || char === '\t') return null;
if (char === '@') {
// @ must be at start or after whitespace/newline
if (i > 0) {
const preceding = beforeCursor[i - 1];
if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') {
return null;
}
}
const query = beforeCursor.slice(i + 1);
return { triggerIndex: i, query };
}
}
return null;
}
export function useMentionDetection({
suggestions,
value,
onValueChange,
textareaRef,
}: UseMentionDetectionOptions): UseMentionDetectionResult {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const triggerIndexRef = useRef<number>(-1);
const filteredSuggestions = useMemo(() => {
if (!isOpen) return [];
if (!query) return suggestions;
const lower = query.toLowerCase();
return suggestions.filter((s) => s.name.toLowerCase().includes(lower));
}, [isOpen, query, suggestions]);
const dismiss = useCallback(() => {
setIsOpen(false);
setQuery('');
setSelectedIndex(0);
setDropdownPosition(null);
triggerIndexRef.current = -1;
}, []);
const computeDropdownPosition = useCallback(
(triggerIdx: number, text: string): void => {
const textarea = textareaRef.current;
if (!textarea) return;
const coords = getCaretCoordinates(textarea, triggerIdx, text);
setDropdownPosition({
top: coords.top + coords.height,
left: 0,
});
},
[textareaRef]
);
const selectSuggestion = useCallback(
(s: MentionSuggestion) => {
const textarea = textareaRef.current;
if (!textarea || triggerIndexRef.current < 0) return;
const before = value.slice(0, triggerIndexRef.current);
const after = value.slice(triggerIndexRef.current + 1 + query.length);
const insertion = `@${s.name} `;
const newValue = before + insertion + after;
const newCursorPos = before.length + insertion.length;
onValueChange(newValue);
dismiss();
// Set cursor position after React re-render
requestAnimationFrame(() => {
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
});
},
[value, query, onValueChange, textareaRef, dismiss]
);
const detectTrigger = useCallback(
(cursorPos: number) => {
const trigger = findMentionTrigger(value, cursorPos);
if (trigger && suggestions.length > 0) {
triggerIndexRef.current = trigger.triggerIndex;
setQuery(trigger.query);
setIsOpen(true);
setSelectedIndex(0);
computeDropdownPosition(trigger.triggerIndex, value);
} else {
dismiss();
}
},
[value, suggestions.length, dismiss, computeDropdownPosition]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
onValueChange(newValue);
// Detect trigger based on cursor position after the change
const cursorPos = e.target.selectionStart;
const trigger = findMentionTrigger(newValue, cursorPos);
if (trigger && suggestions.length > 0) {
triggerIndexRef.current = trigger.triggerIndex;
setQuery(trigger.query);
setIsOpen(true);
setSelectedIndex(0);
computeDropdownPosition(trigger.triggerIndex, newValue);
} else {
dismiss();
}
},
[onValueChange, suggestions.length, dismiss, computeDropdownPosition]
);
const handleSelect = useCallback(
(e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement;
detectTrigger(target.selectionStart);
},
[detectTrigger]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!isOpen || filteredSuggestions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredSuggestions.length);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(
(prev) => (prev - 1 + filteredSuggestions.length) % filteredSuggestions.length
);
break;
case 'Enter':
e.preventDefault();
selectSuggestion(filteredSuggestions[selectedIndex]);
break;
case 'Escape':
e.preventDefault();
dismiss();
break;
}
},
[isOpen, filteredSuggestions, selectedIndex, selectSuggestion, dismiss]
);
return {
isOpen,
query,
filteredSuggestions,
selectedIndex,
dropdownPosition,
selectSuggestion,
dismiss,
handleKeyDown,
handleChange,
handleSelect,
};
}

View file

@ -67,6 +67,7 @@ export interface TeamSlice {
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
startTask: (teamName: string, taskId: string) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
@ -361,6 +362,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return task;
},
startTask: async (teamName: string, taskId: string) => {
await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
await get().refreshTeamData(teamName);
},
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
await unwrapIpc('team:updateTaskStatus', () =>
api.teams.updateTaskStatus(teamName, taskId, status)

View file

@ -0,0 +1,10 @@
export interface MentionSuggestion {
/** Unique key (name or draft.id) */
id: string;
/** Name to insert: @name */
name: string;
/** Role displayed in suggestion list */
subtitle?: string;
/** Color name from TeamColorSet palette */
color?: string;
}

View file

@ -0,0 +1,22 @@
/**
* Fenced code block marker for agent-only content.
* Content wrapped in these markers is intended for the agent (Claude Code)
* and should be hidden from the human user in the UI.
*
* Format:
* ```info_for_agent
* ... agent-only instructions ...
* ```
*/
export const AGENT_BLOCK_TAG = 'info_for_agent';
export const AGENT_BLOCK_OPEN = '```' + AGENT_BLOCK_TAG;
export const AGENT_BLOCK_CLOSE = '```';
/**
* Regex that matches a full ``` info_for_agent ... ``` block (including fences).
* Supports optional leading/trailing whitespace and newlines around the block.
*/
export const AGENT_BLOCK_REGEX = new RegExp(
'\\n?```' + AGENT_BLOCK_TAG + '\\n[\\s\\S]*?\\n```\\n?',
'g'
);

View file

@ -2,6 +2,7 @@
* Shared constants barrel export.
*/
export * from './agentBlocks';
export * from './cache';
export * from './memberColors';
export * from './trafficLights';

View file

@ -354,6 +354,7 @@ export interface TeamsAPI {
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<void>;
processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>;
aliveList: () => Promise<string[]>;

View file

@ -147,6 +147,7 @@ export interface CreateTaskRequest {
owner?: string;
blockedBy?: string[];
prompt?: string;
startImmediately?: boolean;
}
export interface TeamChangeEvent {

View file

@ -22,6 +22,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
TEAM_UPDATE_CONFIG: 'team:updateConfig',
TEAM_START_TASK: 'team:startTask',
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
}));
@ -42,6 +43,7 @@ import {
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_GET_MEMBER_LOGS,
TEAM_START_TASK,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
@ -72,6 +74,7 @@ describe('ipc teams handlers', () => {
requestReview: vi.fn(async () => undefined),
updateKanban: vi.fn(async () => undefined),
updateTaskStatus: vi.fn(async () => undefined),
startTask: vi.fn(async () => undefined),
};
const provisioningService = {
prepareForProvisioning: vi.fn(async () => ({
@ -115,6 +118,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
expect(handlers.has(TEAM_START_TASK)).toBe(true);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
@ -196,6 +200,7 @@ describe('ipc teams handlers', () => {
owner: undefined,
blockedBy: undefined,
prompt: 'Custom instructions here',
startImmediately: undefined,
});
});
@ -231,6 +236,7 @@ describe('ipc teams handlers', () => {
owner: undefined,
blockedBy: undefined,
prompt: undefined,
startImmediately: undefined,
});
});
});
@ -277,6 +283,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
expect(handlers.has(TEAM_START_TASK)).toBe(false);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);

View file

@ -96,4 +96,153 @@ describe('TeamMemberLogsFinder', () => {
expect(lead?.sessionId).toBe(leadSessionId);
expect(lead?.projectId).toBe(projectId);
});
it('detects member via teammate_id attribute in <teammate-message> tag', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 't2';
const projectPath = '/Users/test/proj2';
const projectId = '-Users-test-proj2';
const leadSessionId = 's2';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
// Lead session file
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
JSON.stringify({
timestamp: '2026-01-01T00:00:00.000Z',
type: 'user',
message: { role: 'user', content: 'Start' },
}) + '\n',
'utf8'
);
// Subagent file using <teammate-message> format (no "You are" pattern)
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-xyz789.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: {
role: 'user',
content:
'<teammate-message teammate_id="alice" color="green" summary="Implement feature X">Please implement the login page</teammate-message>',
},
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:05.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Working on it' }] },
}),
].join('\n') + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
const aliceLogs = await finder.findMemberLogs(teamName, 'alice');
expect(aliceLogs).toHaveLength(1);
expect(aliceLogs[0]?.kind).toBe('subagent');
if (aliceLogs[0]?.kind === 'subagent') {
expect(aliceLogs[0].subagentId).toBe('xyz789');
expect(aliceLogs[0].description).toBe('Implement feature X');
}
});
it('reports accurate messageCount from full file (not limited by scan lines)', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 't3';
const projectPath = '/Users/test/proj3';
const projectId = '-Users-test-proj3';
const leadSessionId = 's3';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'carol', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
JSON.stringify({
timestamp: '2026-01-01T00:00:00.000Z',
type: 'user',
message: { role: 'user', content: 'Go' },
}) + '\n',
'utf8'
);
// Build a 200-line subagent file — well beyond ATTRIBUTION_SCAN_LINES (50)
const lines: string[] = [];
// First line: spawn prompt with teammate_id
lines.push(
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: {
role: 'user',
content:
'<teammate-message teammate_id="carol" color="yellow" summary="Big task">Do 200 things</teammate-message>',
},
})
);
// Lines 2-200: alternating assistant/user messages
for (let i = 2; i <= 200; i++) {
const role = i % 2 === 0 ? 'assistant' : 'user';
lines.push(
JSON.stringify({
timestamp: `2026-01-01T00:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}.000Z`,
type: role,
message: { role, content: `Message ${i}` },
})
);
}
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-big123.jsonl'),
lines.join('\n') + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
const carolLogs = await finder.findMemberLogs(teamName, 'carol');
expect(carolLogs).toHaveLength(1);
expect(carolLogs[0]?.kind).toBe('subagent');
// Full file has 200 messages — must NOT be capped at 50 or 100
expect(carolLogs[0]?.messageCount).toBe(200);
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { findMentionTrigger } from '@renderer/hooks/useMentionDetection';
describe('findMentionTrigger', () => {
it('detects @query at start of text', () => {
const result = findMentionTrigger('@ali', 4);
expect(result).toEqual({ triggerIndex: 0, query: 'ali' });
});
it('detects @query after space', () => {
const result = findMentionTrigger('hello @bo', 9);
expect(result).toEqual({ triggerIndex: 6, query: 'bo' });
});
it('returns null for email-like @ (no space before)', () => {
const result = findMentionTrigger('email@test', 10);
expect(result).toBeNull();
});
it('returns null when space follows @ query (mention already complete)', () => {
const result = findMentionTrigger('@alice ', 7);
expect(result).toBeNull();
});
it('returns empty query for bare @', () => {
const result = findMentionTrigger('@', 1);
expect(result).toEqual({ triggerIndex: 0, query: '' });
});
it('detects @ after newline', () => {
const result = findMentionTrigger('text\n@ca', 8);
expect(result).toEqual({ triggerIndex: 5, query: 'ca' });
});
it('returns null for empty text', () => {
const result = findMentionTrigger('', 0);
expect(result).toBeNull();
});
it('detects @ after tab', () => {
const result = findMentionTrigger('hello\t@bob', 10);
expect(result).toEqual({ triggerIndex: 6, query: 'bob' });
});
it('returns null when cursor is at position 0', () => {
const result = findMentionTrigger('@test', 0);
expect(result).toBeNull();
});
it('detects @ with empty query after space', () => {
const result = findMentionTrigger('hello @', 7);
expect(result).toEqual({ triggerIndex: 6, query: '' });
});
it('handles multiple @ signs - picks nearest valid one', () => {
const result = findMentionTrigger('@alice hello @bo', 16);
expect(result).toEqual({ triggerIndex: 13, query: 'bo' });
});
it('returns null for @ in middle of word', () => {
const result = findMentionTrigger('test@domain', 11);
expect(result).toBeNull();
});
it('detects @ after carriage return', () => {
const result = findMentionTrigger('text\r\n@ca', 9);
expect(result).toEqual({ triggerIndex: 6, query: 'ca' });
});
});