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:
parent
3c1ef54ce2
commit
bcda8b62cc
29 changed files with 1872 additions and 268 deletions
|
|
@ -18,6 +18,7 @@ import {
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
|
TEAM_START_TASK,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
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_LOGS, handleGetMemberLogs);
|
||||||
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
|
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
|
||||||
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
|
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
|
||||||
|
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
||||||
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
||||||
logger.info('Team handlers registered');
|
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_LOGS);
|
||||||
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
|
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
|
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
|
||||||
|
ipcMain.removeHandler(TEAM_START_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,6 +534,9 @@ async function handleCreateTask(
|
||||||
return { success: false, error: 'prompt exceeds max length (5000)' };
|
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', () =>
|
return wrapTeamHandler('createTask', () =>
|
||||||
getTeamDataService().createTask(validatedTeamName.value!, {
|
getTeamDataService().createTask(validatedTeamName.value!, {
|
||||||
|
|
@ -539,6 +545,7 @@ async function handleCreateTask(
|
||||||
owner: payload.owner?.trim() || undefined,
|
owner: payload.owner?.trim() || undefined,
|
||||||
blockedBy: payload.blockedBy,
|
blockedBy: payload.blockedBy,
|
||||||
prompt: payload.prompt?.trim() || undefined,
|
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());
|
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[]>> {
|
async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
|
||||||
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import * as path from 'path';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
const TOOL_FILE_NAME = 'teamctl.js';
|
const TOOL_FILE_NAME = 'teamctl.js';
|
||||||
const TOOL_VERSION = 3;
|
const TOOL_VERSION = 4;
|
||||||
|
|
||||||
function buildTeamCtlScript(): string {
|
function buildTeamCtlScript(): string {
|
||||||
const script = String.raw`#!/usr/bin/env node
|
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 set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
|
||||||
' node teamctl.js task complete <id> [--team <team>]',
|
' node teamctl.js task complete <id> [--team <team>]',
|
||||||
' node teamctl.js task start <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 set-column <id> <review|approved> [--team <team>]',
|
||||||
' node teamctl.js kanban clear <id> [--team <team>]',
|
' node teamctl.js kanban clear <id> [--team <team>]',
|
||||||
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--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) {
|
if (notify && task.owner) {
|
||||||
const from =
|
const from =
|
||||||
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user';
|
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user';
|
||||||
const text =
|
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
|
||||||
'New task assigned to you: #' +
|
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
||||||
String(task.id) +
|
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
|
||||||
' "' +
|
if (rawDesc && rawDesc !== task.subject) {
|
||||||
String(task.subject) +
|
parts.push('\nDescription:\n' + rawDesc);
|
||||||
'".\n\n' +
|
}
|
||||||
'When you start: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
|
const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : '';
|
||||||
String(teamName) +
|
if (prompt) {
|
||||||
' task start ' +
|
parts.push('\nInstructions:\n' + prompt);
|
||||||
String(task.id) +
|
}
|
||||||
'\n' +
|
parts.push(
|
||||||
'When done: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
|
'\n${'```'}info_for_agent',
|
||||||
String(teamName) +
|
'Update task status using:',
|
||||||
' task complete ' +
|
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
|
||||||
String(task.id) +
|
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
||||||
'\n';
|
'${'```'}'
|
||||||
|
);
|
||||||
sendInboxMessage(paths, teamName, {
|
sendInboxMessage(paths, teamName, {
|
||||||
to: task.owner,
|
to: task.owner,
|
||||||
text,
|
text: parts.join('\n'),
|
||||||
summary: 'New task #' + String(task.id) + ' assigned',
|
summary: 'New task #' + String(task.id) + ' assigned',
|
||||||
from,
|
from,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
@ -216,12 +217,14 @@ export class TeamDataService {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldStart = request.owner && request.startImmediately !== false;
|
||||||
|
|
||||||
const task: TeamTask = {
|
const task: TeamTask = {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
subject: request.subject,
|
subject: request.subject,
|
||||||
description,
|
description,
|
||||||
owner: request.owner,
|
owner: request.owner,
|
||||||
status: request.owner ? 'in_progress' : 'pending',
|
status: shouldStart ? 'in_progress' : 'pending',
|
||||||
blocks: [],
|
blocks: [],
|
||||||
blockedBy,
|
blockedBy,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
|
@ -234,18 +237,33 @@ export class TeamDataService {
|
||||||
await this.taskWriter.addBlocksEntry(teamName, depId, nextId);
|
await this.taskWriter.addBlocksEntry(teamName, depId, nextId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.owner) {
|
if (shouldStart && request.owner) {
|
||||||
try {
|
try {
|
||||||
const toolPath = await this.toolsInstaller.ensureInstalled();
|
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, {
|
await this.sendMessage(teamName, {
|
||||||
member: request.owner,
|
member: request.owner,
|
||||||
text:
|
text: parts.join('\n'),
|
||||||
`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`,
|
|
||||||
summary: `New task #${task.id} assigned`,
|
summary: `New task #${task.id} assigned`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -256,6 +274,42 @@ export class TeamDataService {
|
||||||
return task;
|
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> {
|
async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, status);
|
await this.taskWriter.updateStatus(teamName, taskId, status);
|
||||||
}
|
}
|
||||||
|
|
@ -279,10 +333,12 @@ export class TeamDataService {
|
||||||
member: reviewer,
|
member: reviewer,
|
||||||
text:
|
text:
|
||||||
`Please review task #${taskId}.\n\n` +
|
`Please review task #${taskId}.\n\n` +
|
||||||
|
`${AGENT_BLOCK_OPEN}\n` +
|
||||||
`When approved, move it to APPROVED:\n` +
|
`When approved, move it to APPROVED:\n` +
|
||||||
`node "${toolPath}" --team ${teamName} review approve ${taskId}\n\n` +
|
`node "${toolPath}" --team ${teamName} review approve ${taskId}\n\n` +
|
||||||
`If changes are needed:\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}`,
|
summary: `Review request for #${taskId}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -13,7 +14,18 @@ import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamMemberLogsFinder');
|
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 {
|
function trimTrailingSlashes(value: string): string {
|
||||||
let end = value.length;
|
let end = value.length;
|
||||||
|
|
@ -126,16 +138,11 @@ export class TeamMemberLogsFinder {
|
||||||
if (file.startsWith('agent-acompact')) continue;
|
if (file.startsWith('agent-acompact')) continue;
|
||||||
|
|
||||||
const filePath = path.join(subagentsDir, file);
|
const filePath = path.join(subagentsDir, file);
|
||||||
// Quick attribution check — reuse parseSubagentSummary to verify membership
|
// Quick attribution check — only Phase 1 (no full-file streaming)
|
||||||
const summary = await this.parseSubagentSummary(
|
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||||
filePath,
|
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
|
||||||
projectId,
|
paths.push(filePath);
|
||||||
sessionId,
|
}
|
||||||
file,
|
|
||||||
memberName,
|
|
||||||
knownMembers
|
|
||||||
);
|
|
||||||
if (summary) paths.push(filePath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,21 +175,35 @@ export class TeamMemberLogsFinder {
|
||||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||||
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
|
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) {
|
if (config.leadSessionId) {
|
||||||
const leadDir = path.join(projectDir, config.leadSessionId);
|
knownSessionIds.add(config.leadSessionId);
|
||||||
try {
|
}
|
||||||
const stat = await fs.stat(leadDir);
|
if (Array.isArray(config.sessionHistory)) {
|
||||||
if (stat.isDirectory()) {
|
for (const sid of config.sessionHistory) {
|
||||||
sessionIds = [config.leadSessionId];
|
if (typeof sid === 'string' && sid.trim().length > 0) {
|
||||||
} else {
|
knownSessionIds.add(sid.trim());
|
||||||
logger.debug(`leadSessionId dir is not a directory: ${leadDir}`);
|
|
||||||
sessionIds = await this.listSessionDirs(projectDir);
|
|
||||||
}
|
}
|
||||||
} 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 {
|
} else {
|
||||||
sessionIds = await this.listSessionDirs(projectDir);
|
sessionIds = await this.listSessionDirs(projectDir);
|
||||||
}
|
}
|
||||||
|
|
@ -237,112 +258,28 @@ export class TeamMemberLogsFinder {
|
||||||
knownMembers: Set<string>
|
knownMembers: Set<string>
|
||||||
): Promise<MemberSubagentLogSummary | null> {
|
): Promise<MemberSubagentLogSummary | null> {
|
||||||
const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
try {
|
// ── Phase 1: Attribution (first N lines) ──
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
// Detect which member owns this file + extract description.
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
// 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();
|
const targetLower = targetMember.toLowerCase();
|
||||||
|
if (attribution.detectedMember.toLowerCase() !== targetLower) {
|
||||||
// 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) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firstTimestamp) {
|
// ── Phase 2: Metadata (stream entire file) ──
|
||||||
// Fallback: use file mtime
|
// Now that we know the file belongs to this member, collect
|
||||||
try {
|
// accurate timestamps and message count from the full file.
|
||||||
const stat = await fs.stat(filePath);
|
const metadata = await this.streamFileMetadata(filePath);
|
||||||
firstTimestamp = stat.mtime.toISOString();
|
|
||||||
lastTimestamp = firstTimestamp;
|
const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(filePath));
|
||||||
} catch {
|
const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
|
||||||
firstTimestamp = new Date().toISOString();
|
|
||||||
lastTimestamp = firstTimestamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = new Date(firstTimestamp);
|
const startTime = new Date(firstTimestamp);
|
||||||
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
|
const endTime = new Date(lastTimestamp);
|
||||||
const durationMs = endTime.getTime() - startTime.getTime();
|
const durationMs = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
// Check if the file might still be active (modified recently)
|
// Check if the file might still be active (modified recently)
|
||||||
|
|
@ -360,15 +297,135 @@ export class TeamMemberLogsFinder {
|
||||||
subagentId,
|
subagentId,
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
description: description || `Subagent ${subagentId}`,
|
description: attribution.description || `Subagent ${subagentId}`,
|
||||||
memberName: targetMember,
|
memberName: targetMember,
|
||||||
startTime: firstTimestamp,
|
startTime: firstTimestamp,
|
||||||
durationMs: Math.max(0, durationMs),
|
durationMs: Math.max(0, durationMs),
|
||||||
messageCount,
|
messageCount: metadata.messageCount,
|
||||||
isOngoing,
|
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.
|
* Detects the member name from a parsed JSONL message using multiple signals.
|
||||||
* Returns a detection result with the name and a priority level:
|
* Returns a detection result with the name and a priority level:
|
||||||
|
|
@ -463,49 +520,13 @@ export class TeamMemberLogsFinder {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let firstTimestamp: string | null = null;
|
const metadata = await this.streamFileMetadata(jsonlPath);
|
||||||
let lastTimestamp: string | null = null;
|
|
||||||
let messageCount = 0;
|
|
||||||
|
|
||||||
try {
|
const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(jsonlPath));
|
||||||
const stream = createReadStream(jsonlPath, { encoding: 'utf8' });
|
const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
|
||||||
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 startTime = new Date(firstTimestamp);
|
const startTime = new Date(firstTimestamp);
|
||||||
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
|
const endTime = new Date(lastTimestamp);
|
||||||
const durationMs = endTime.getTime() - startTime.getTime();
|
const durationMs = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
let isOngoing = false;
|
let isOngoing = false;
|
||||||
|
|
@ -525,10 +546,55 @@ export class TeamMemberLogsFinder {
|
||||||
memberName,
|
memberName,
|
||||||
startTime: firstTimestamp,
|
startTime: firstTimestamp,
|
||||||
durationMs: Math.max(0, durationMs),
|
durationMs: Math.max(0, durationMs),
|
||||||
messageCount,
|
messageCount: metadata.messageCount,
|
||||||
isOngoing,
|
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 {
|
function findOriginalCase(text: string, lowerName: string): string {
|
||||||
|
|
|
||||||
|
|
@ -242,5 +242,8 @@ export const TEAM_UPDATE_CONFIG = 'team:updateConfig';
|
||||||
/** Get aggregated member stats */
|
/** Get aggregated member stats */
|
||||||
export const TEAM_GET_MEMBER_STATS = 'team:getMemberStats';
|
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 */
|
/** Get all tasks across all teams */
|
||||||
export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';
|
export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import {
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
|
TEAM_START_TASK,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
|
|
@ -205,8 +206,7 @@ const electronAPI: ElectronAPI = {
|
||||||
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
|
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
|
||||||
|
|
||||||
// Agent config reading
|
// Agent config reading
|
||||||
readAgentConfigs: (projectRoot: string) =>
|
readAgentConfigs: (projectRoot: string) => ipcRenderer.invoke('read-agent-configs', projectRoot),
|
||||||
ipcRenderer.invoke('read-agent-configs', projectRoot),
|
|
||||||
|
|
||||||
// Notifications API
|
// Notifications API
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|
@ -540,6 +540,9 @@ const electronAPI: ElectronAPI = {
|
||||||
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
|
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) => {
|
processSend: async (teamName: string, message: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message);
|
return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -668,6 +668,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
throw new Error('Team task status update is not available in browser mode');
|
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> => {
|
processSend: async (_teamName: string, _message: string): Promise<void> => {
|
||||||
throw new Error('Team process communication is not available in browser mode');
|
throw new Error('Team process communication is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -78,12 +78,14 @@ export const GlobalTaskList = (): React.JSX.Element => {
|
||||||
const [filter, setFilter] = useState<StatusFilter>('all');
|
const [filter, setFilter] = useState<StatusFilter>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const hasFetchedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (globalTasks.length === 0 && !globalTasksLoading) {
|
if (!hasFetchedRef.current) {
|
||||||
|
hasFetchedRef.current = true;
|
||||||
void fetchAllTasks();
|
void fetchAllTasks();
|
||||||
}
|
}
|
||||||
}, [globalTasks.length, globalTasksLoading, fetchAllTasks]);
|
}, [fetchAllTasks]);
|
||||||
|
|
||||||
const selectedProjectPath = useMemo(() => {
|
const selectedProjectPath = useMemo(() => {
|
||||||
if (viewMode === 'grouped') {
|
if (viewMode === 'grouped') {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||||||
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
||||||
|
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
||||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||||
|
|
@ -58,6 +59,7 @@ function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] {
|
||||||
|
|
||||||
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
|
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
|
||||||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||||||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||||
open: false,
|
open: false,
|
||||||
|
|
@ -91,6 +93,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
sendTeamMessage,
|
sendTeamMessage,
|
||||||
requestReview,
|
requestReview,
|
||||||
createTeamTask,
|
createTeamTask,
|
||||||
|
startTask,
|
||||||
deleteTeam,
|
deleteTeam,
|
||||||
openTeamsTab,
|
openTeamsTab,
|
||||||
sendingMessage,
|
sendingMessage,
|
||||||
|
|
@ -113,6 +116,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
sendTeamMessage: s.sendTeamMessage,
|
sendTeamMessage: s.sendTeamMessage,
|
||||||
requestReview: s.requestReview,
|
requestReview: s.requestReview,
|
||||||
createTeamTask: s.createTeamTask,
|
createTeamTask: s.createTeamTask,
|
||||||
|
startTask: s.startTask,
|
||||||
deleteTeam: s.deleteTeam,
|
deleteTeam: s.deleteTeam,
|
||||||
openTeamsTab: s.openTeamsTab,
|
openTeamsTab: s.openTeamsTab,
|
||||||
sendingMessage: s.sendingMessage,
|
sendingMessage: s.sendingMessage,
|
||||||
|
|
@ -257,6 +261,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return filterKanbanTasks(filteredTasks, query);
|
return filterKanbanTasks(filteredTasks, query);
|
||||||
}, [filteredTasks, kanbanSearch]);
|
}, [filteredTasks, kanbanSearch]);
|
||||||
|
|
||||||
|
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||||
|
|
||||||
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
|
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
|
||||||
setCreateTaskDialog({
|
setCreateTaskDialog({
|
||||||
open: true,
|
open: true,
|
||||||
|
|
@ -297,7 +303,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
description: string,
|
description: string,
|
||||||
owner?: string,
|
owner?: string,
|
||||||
blockedBy?: string[],
|
blockedBy?: string[],
|
||||||
prompt?: string
|
prompt?: string,
|
||||||
|
startImmediately?: boolean
|
||||||
): void => {
|
): void => {
|
||||||
setCreatingTask(true);
|
setCreatingTask(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -308,9 +315,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
owner,
|
owner,
|
||||||
blockedBy,
|
blockedBy,
|
||||||
prompt,
|
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}`;
|
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
|
||||||
try {
|
try {
|
||||||
await api.teams.processSend(teamName, msg);
|
await api.teams.processSend(teamName, msg);
|
||||||
|
|
@ -534,6 +542,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onMoveBackToDone={(taskId) => {
|
onMoveBackToDone={(taskId) => {
|
||||||
void updateKanban(teamName, taskId, { op: 'remove' });
|
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) => {
|
onCompleteTask={(taskId) => {
|
||||||
void updateTaskStatus(teamName, taskId, 'completed');
|
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);
|
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onTaskClick={(task) => setSelectedTask(task)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
@ -664,6 +695,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
}}
|
}}
|
||||||
onClose={() => setSendDialogOpen(false)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
|
|
@ -12,7 +14,8 @@ import {
|
||||||
parseStructuredAgentMessage,
|
parseStructuredAgentMessage,
|
||||||
} from '@renderer/utils/agentMessageFormatting';
|
} from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
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 { TeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import type { InboxMessage } from '@shared/types';
|
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 = ({
|
export const ActivityItem = ({
|
||||||
|
|
@ -105,6 +132,16 @@ export const ActivityItem = ({
|
||||||
const structured = parseStructuredAgentMessage(message.text);
|
const structured = parseStructuredAgentMessage(message.text);
|
||||||
const noiseLabel = structured ? getNoiseLabel(structured) : null;
|
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
|
// Noise messages: minimal inline row
|
||||||
if (noiseLabel) {
|
if (noiseLabel) {
|
||||||
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
|
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
|
||||||
|
|
@ -132,8 +169,37 @@ export const ActivityItem = ({
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — clickable when system message to toggle expand */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<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' ? (
|
{message.source === 'lead_session' ? (
|
||||||
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -159,8 +225,12 @@ export const ActivityItem = ({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Message type label */}
|
{/* Message type label or system label */}
|
||||||
{messageType ? (
|
{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 }}>
|
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{messageType}
|
{messageType}
|
||||||
</span>
|
</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"
|
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
title="Create task from message"
|
title="Create task from message"
|
||||||
onClick={handleCreateTask}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCreateTask();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListPlus size={14} />
|
<ListPlus size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -204,26 +277,28 @@ export const ActivityItem = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content — always expanded */}
|
{/* Content — collapsed for system messages, expanded for others */}
|
||||||
<div className="px-3 pb-3">
|
{isExpanded ? (
|
||||||
{structured ? (
|
<div className="px-3 pb-3">
|
||||||
<div className="space-y-2">
|
{structured ? (
|
||||||
{autoSummary && autoSummary !== messageType ? (
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
|
{autoSummary && autoSummary !== messageType ? (
|
||||||
) : null}
|
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
|
||||||
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
) : null}
|
||||||
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
|
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||||
Raw JSON
|
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
|
||||||
</summary>
|
Raw JSON
|
||||||
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
</summary>
|
||||||
{JSON.stringify(structured, null, 2)}
|
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||||
</pre>
|
{JSON.stringify(structured, null, 2)}
|
||||||
</details>
|
</pre>
|
||||||
</div>
|
</details>
|
||||||
) : (
|
</div>
|
||||||
<MarkdownViewer content={message.text} maxHeight="max-h-56" copyable />
|
) : (
|
||||||
)}
|
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -20,9 +21,11 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
||||||
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
interface CreateTaskDialogProps {
|
interface CreateTaskDialogProps {
|
||||||
|
|
@ -38,7 +41,8 @@ interface CreateTaskDialogProps {
|
||||||
description: string,
|
description: string,
|
||||||
owner?: string,
|
owner?: string,
|
||||||
blockedBy?: string[],
|
blockedBy?: string[],
|
||||||
prompt?: string
|
prompt?: string,
|
||||||
|
startImmediately?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
submitting?: boolean;
|
submitting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +65,7 @@ export const CreateTaskDialog = ({
|
||||||
});
|
});
|
||||||
const [owner, setOwner] = useState<string>(defaultOwner);
|
const [owner, setOwner] = useState<string>(defaultOwner);
|
||||||
const [blockedBy, setBlockedBy] = useState<string[]>([]);
|
const [blockedBy, setBlockedBy] = useState<string[]>([]);
|
||||||
|
const [startImmediately, setStartImmediately] = useState(true);
|
||||||
const promptDraft = useDraftPersistence({ key: 'createTask:prompt' });
|
const promptDraft = useDraftPersistence({ key: 'createTask:prompt' });
|
||||||
const [prevOpen, setPrevOpen] = useState(false);
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -71,12 +76,24 @@ export const CreateTaskDialog = ({
|
||||||
}
|
}
|
||||||
setOwner(defaultOwner);
|
setOwner(defaultOwner);
|
||||||
setBlockedBy([]);
|
setBlockedBy([]);
|
||||||
|
setStartImmediately(true);
|
||||||
promptDraft.clearDraft();
|
promptDraft.clearDraft();
|
||||||
}
|
}
|
||||||
if (open !== prevOpen) {
|
if (open !== prevOpen) {
|
||||||
setPrevOpen(open);
|
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;
|
const canSubmit = subject.trim().length > 0 && !submitting;
|
||||||
|
|
||||||
// Only show non-internal, non-deleted tasks as candidates for blocking
|
// Only show non-internal, non-deleted tasks as candidates for blocking
|
||||||
|
|
@ -95,7 +112,8 @@ export const CreateTaskDialog = ({
|
||||||
descriptionDraft.value.trim(),
|
descriptionDraft.value.trim(),
|
||||||
owner || undefined,
|
owner || undefined,
|
||||||
blockedBy.length > 0 ? blockedBy : undefined,
|
blockedBy.length > 0 ? blockedBy : undefined,
|
||||||
promptDraft.value.trim() || undefined
|
promptDraft.value.trim() || undefined,
|
||||||
|
startImmediately
|
||||||
);
|
);
|
||||||
descriptionDraft.clearDraft();
|
descriptionDraft.clearDraft();
|
||||||
promptDraft.clearDraft();
|
promptDraft.clearDraft();
|
||||||
|
|
@ -135,32 +153,38 @@ export const CreateTaskDialog = ({
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="task-description">Description (optional)</Label>
|
<Label htmlFor="task-description">Description (optional)</Label>
|
||||||
<AutoResizeTextarea
|
<MentionableTextarea
|
||||||
id="task-description"
|
id="task-description"
|
||||||
placeholder="Task details..."
|
placeholder="Task details..."
|
||||||
value={descriptionDraft.value}
|
value={descriptionDraft.value}
|
||||||
|
onValueChange={descriptionDraft.setValue}
|
||||||
|
suggestions={mentionSuggestions}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
maxRows={12}
|
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>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label>
|
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label>
|
||||||
<AutoResizeTextarea
|
<MentionableTextarea
|
||||||
id="task-prompt"
|
id="task-prompt"
|
||||||
placeholder="Custom instructions for the team member..."
|
placeholder="Custom instructions for the team member..."
|
||||||
value={promptDraft.value}
|
value={promptDraft.value}
|
||||||
|
onValueChange={promptDraft.setValue}
|
||||||
|
suggestions={mentionSuggestions}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
maxRows={12}
|
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>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
|
@ -175,11 +199,24 @@ export const CreateTaskDialog = ({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||||
{members.map((m) => {
|
{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 (
|
return (
|
||||||
<SelectItem key={m.name} value={m.name}>
|
<SelectItem key={m.name} value={m.name}>
|
||||||
{m.name}
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{role ? ` (${role})` : ''}
|
{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>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -187,6 +224,19 @@ export const CreateTaskDialog = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 ? (
|
{availableTasks.length > 0 ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Blocked by tasks (optional)</Label>
|
<Label>Blocked by tasks (optional)</Label>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -39,6 +40,7 @@ const TEAM_COLOR_NAMES = [
|
||||||
'pink',
|
'pink',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
|
|
@ -426,6 +428,24 @@ export const CreateTeamDialog = ({
|
||||||
const description = descriptionDraft.value;
|
const description = descriptionDraft.value;
|
||||||
const prompt = promptDraft.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>(
|
const request = useMemo<TeamCreateRequest>(
|
||||||
() => ({
|
() => ({
|
||||||
teamName: teamName.trim(),
|
teamName: teamName.trim(),
|
||||||
|
|
@ -744,18 +764,21 @@ export const CreateTeamDialog = ({
|
||||||
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
|
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
|
||||||
Prompt for team lead (optional)
|
Prompt for team lead (optional)
|
||||||
</Label>
|
</Label>
|
||||||
<AutoResizeTextarea
|
<MentionableTextarea
|
||||||
id="team-prompt"
|
id="team-prompt"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
minRows={3}
|
minRows={3}
|
||||||
maxRows={12}
|
maxRows={12}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(event) => promptDraft.setValue(event.target.value)}
|
onValueChange={promptDraft.setValue}
|
||||||
|
suggestions={mentionSuggestions}
|
||||||
placeholder="Instructions for the team lead during provisioning..."
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -12,6 +11,7 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -19,9 +19,11 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
||||||
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||||
|
|
||||||
interface SendMessageDialogProps {
|
interface SendMessageDialogProps {
|
||||||
|
|
@ -72,6 +74,17 @@ export const SendMessageDialog = ({
|
||||||
onClose();
|
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 canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
|
|
@ -107,11 +120,24 @@ export const SendMessageDialog = ({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
|
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
|
||||||
{members.map((m) => {
|
{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 (
|
return (
|
||||||
<SelectItem key={m.name} value={m.name}>
|
<SelectItem key={m.name} value={m.name}>
|
||||||
{m.name}
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{role ? ` (${role})` : ''}
|
{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>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -131,17 +157,20 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="smd-message">Message</Label>
|
<Label htmlFor="smd-message">Message</Label>
|
||||||
<AutoResizeTextarea
|
<MentionableTextarea
|
||||||
id="smd-message"
|
id="smd-message"
|
||||||
placeholder="Write your message..."
|
placeholder="Write your message..."
|
||||||
value={textDraft.value}
|
value={textDraft.value}
|
||||||
|
onValueChange={textDraft.setValue}
|
||||||
|
suggestions={mentionSuggestions}
|
||||||
minRows={4}
|
minRows={4}
|
||||||
maxRows={12}
|
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>
|
</div>
|
||||||
|
|
||||||
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
|
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
|
||||||
|
|
|
||||||
214
src/renderer/components/team/dialogs/TaskDetailDialog.tsx
Normal file
214
src/renderer/components/team/dialogs/TaskDetailDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -24,8 +24,10 @@ interface KanbanBoardProps {
|
||||||
onApprove: (taskId: string) => void;
|
onApprove: (taskId: string) => void;
|
||||||
onRequestChanges: (taskId: string) => void;
|
onRequestChanges: (taskId: string) => void;
|
||||||
onMoveBackToDone: (taskId: string) => void;
|
onMoveBackToDone: (taskId: string) => void;
|
||||||
|
onStartTask: (taskId: string) => void;
|
||||||
onCompleteTask: (taskId: string) => void;
|
onCompleteTask: (taskId: string) => void;
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
|
onTaskClick?: (task: TeamTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanViewMode = 'grid' | 'columns';
|
type KanbanViewMode = 'grid' | 'columns';
|
||||||
|
|
@ -68,8 +70,10 @@ export const KanbanBoard = ({
|
||||||
onApprove,
|
onApprove,
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
onMoveBackToDone,
|
onMoveBackToDone,
|
||||||
|
onStartTask,
|
||||||
onCompleteTask,
|
onCompleteTask,
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
|
onTaskClick,
|
||||||
}: KanbanBoardProps): React.JSX.Element => {
|
}: KanbanBoardProps): React.JSX.Element => {
|
||||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||||
|
|
||||||
|
|
@ -110,8 +114,10 @@ export const KanbanBoard = ({
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onRequestChanges={onRequestChanges}
|
onRequestChanges={onRequestChanges}
|
||||||
onMoveBackToDone={onMoveBackToDone}
|
onMoveBackToDone={onMoveBackToDone}
|
||||||
|
onStartTask={onStartTask}
|
||||||
onCompleteTask={onCompleteTask}
|
onCompleteTask={onCompleteTask}
|
||||||
onScrollToTask={onScrollToTask}
|
onScrollToTask={onScrollToTask}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
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';
|
import { ReviewBadge } from './ReviewBadge';
|
||||||
|
|
||||||
|
|
@ -16,8 +16,10 @@ interface KanbanTaskCardProps {
|
||||||
onApprove: (taskId: string) => void;
|
onApprove: (taskId: string) => void;
|
||||||
onRequestChanges: (taskId: string) => void;
|
onRequestChanges: (taskId: string) => void;
|
||||||
onMoveBackToDone: (taskId: string) => void;
|
onMoveBackToDone: (taskId: string) => void;
|
||||||
|
onStartTask: (taskId: string) => void;
|
||||||
onCompleteTask: (taskId: string) => void;
|
onCompleteTask: (taskId: string) => void;
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
|
onTaskClick?: (task: TeamTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DependencyBadgeProps {
|
interface DependencyBadgeProps {
|
||||||
|
|
@ -63,8 +65,10 @@ export const KanbanTaskCard = ({
|
||||||
onApprove,
|
onApprove,
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
onMoveBackToDone,
|
onMoveBackToDone,
|
||||||
|
onStartTask,
|
||||||
onCompleteTask,
|
onCompleteTask,
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
|
onTaskClick,
|
||||||
}: KanbanTaskCardProps): React.JSX.Element => {
|
}: KanbanTaskCardProps): React.JSX.Element => {
|
||||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = task.blocks?.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;
|
const hasBlocks = blocksIds.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<div
|
||||||
data-task-id={task.id}
|
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
|
hasBlockedBy
|
||||||
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
|
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
|
||||||
: 'border-[var(--color-border)] 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 className="mb-2 flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -126,13 +139,47 @@ export const KanbanTaskCard = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
aria-label={`Complete task ${task.id}`}
|
aria-label={`Complete task ${task.id}`}
|
||||||
onClick={() => onCompleteTask(task.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCompleteTask(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CheckCircle2 size={12} />
|
<CheckCircle2 size={12} />
|
||||||
Complete
|
Complete
|
||||||
|
|
@ -144,7 +191,10 @@ export const KanbanTaskCard = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={`Request review for task ${task.id}`}
|
aria-label={`Request review for task ${task.id}`}
|
||||||
onClick={() => onRequestReview(task.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRequestReview(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Request Review
|
Request Review
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -160,7 +210,10 @@ export const KanbanTaskCard = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={`Approve task ${task.id}`}
|
aria-label={`Approve task ${task.id}`}
|
||||||
onClick={() => onApprove(task.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApprove(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -168,7 +221,10 @@ export const KanbanTaskCard = ({
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={`Request changes for task ${task.id}`}
|
aria-label={`Request changes for task ${task.id}`}
|
||||||
onClick={() => onRequestChanges(task.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRequestChanges(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Request Changes
|
Request Changes
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -181,11 +237,14 @@ export const KanbanTaskCard = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={`Move task ${task.id} back to done`}
|
aria-label={`Move task ${task.id} back to done`}
|
||||||
onClick={() => onMoveBackToDone(task.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMoveBackToDone(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Move back to DONE
|
Move back to DONE
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
99
src/renderer/components/ui/MentionSuggestionList.tsx
Normal file
99
src/renderer/components/ui/MentionSuggestionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
275
src/renderer/components/ui/MentionableTextarea.tsx
Normal file
275
src/renderer/components/ui/MentionableTextarea.tsx
Normal 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';
|
||||||
|
|
@ -83,6 +83,7 @@ export const Combobox = ({
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
className="max-h-72 overflow-y-auto overscroll-contain p-1"
|
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)]">
|
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
|
|
|
||||||
298
src/renderer/hooks/useMentionDetection.ts
Normal file
298
src/renderer/hooks/useMentionDetection.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,7 @@ export interface TeamSlice {
|
||||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||||
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||||
|
startTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
||||||
|
|
@ -361,6 +362,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return task;
|
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) => {
|
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
||||||
await unwrapIpc('team:updateTaskStatus', () =>
|
await unwrapIpc('team:updateTaskStatus', () =>
|
||||||
api.teams.updateTaskStatus(teamName, taskId, status)
|
api.teams.updateTaskStatus(teamName, taskId, status)
|
||||||
|
|
|
||||||
10
src/renderer/types/mention.ts
Normal file
10
src/renderer/types/mention.ts
Normal 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;
|
||||||
|
}
|
||||||
22
src/shared/constants/agentBlocks.ts
Normal file
22
src/shared/constants/agentBlocks.ts
Normal 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'
|
||||||
|
);
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Shared constants barrel export.
|
* Shared constants barrel export.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export * from './agentBlocks';
|
||||||
export * from './cache';
|
export * from './cache';
|
||||||
export * from './memberColors';
|
export * from './memberColors';
|
||||||
export * from './trafficLights';
|
export * from './trafficLights';
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,7 @@ export interface TeamsAPI {
|
||||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => 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>;
|
processSend: (teamName: string, message: string) => Promise<void>;
|
||||||
processAlive: (teamName: string) => Promise<boolean>;
|
processAlive: (teamName: string) => Promise<boolean>;
|
||||||
aliveList: () => Promise<string[]>;
|
aliveList: () => Promise<string[]>;
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ export interface CreateTaskRequest {
|
||||||
owner?: string;
|
owner?: string;
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
startImmediately?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamChangeEvent {
|
export interface TeamChangeEvent {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
|
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
|
||||||
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
|
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
|
||||||
TEAM_UPDATE_CONFIG: 'team:updateConfig',
|
TEAM_UPDATE_CONFIG: 'team:updateConfig',
|
||||||
|
TEAM_START_TASK: 'team:startTask',
|
||||||
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
|
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ import {
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
|
TEAM_START_TASK,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
|
|
@ -72,6 +74,7 @@ describe('ipc teams handlers', () => {
|
||||||
requestReview: vi.fn(async () => undefined),
|
requestReview: vi.fn(async () => undefined),
|
||||||
updateKanban: vi.fn(async () => undefined),
|
updateKanban: vi.fn(async () => undefined),
|
||||||
updateTaskStatus: vi.fn(async () => undefined),
|
updateTaskStatus: vi.fn(async () => undefined),
|
||||||
|
startTask: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const provisioningService = {
|
const provisioningService = {
|
||||||
prepareForProvisioning: vi.fn(async () => ({
|
prepareForProvisioning: vi.fn(async () => ({
|
||||||
|
|
@ -115,6 +118,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
||||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
||||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).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_SEND)).toBe(true);
|
||||||
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
|
||||||
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
|
||||||
|
|
@ -196,6 +200,7 @@ describe('ipc teams handlers', () => {
|
||||||
owner: undefined,
|
owner: undefined,
|
||||||
blockedBy: undefined,
|
blockedBy: undefined,
|
||||||
prompt: 'Custom instructions here',
|
prompt: 'Custom instructions here',
|
||||||
|
startImmediately: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -231,6 +236,7 @@ describe('ipc teams handlers', () => {
|
||||||
owner: undefined,
|
owner: undefined,
|
||||||
blockedBy: undefined,
|
blockedBy: undefined,
|
||||||
prompt: 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_REQUEST_REVIEW)).toBe(false);
|
||||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
||||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).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_SEND)).toBe(false);
|
||||||
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
|
||||||
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -96,4 +96,153 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
expect(lead?.sessionId).toBe(leadSessionId);
|
expect(lead?.sessionId).toBe(leadSessionId);
|
||||||
expect(lead?.projectId).toBe(projectId);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
70
test/renderer/hooks/useMentionDetection.test.ts
Normal file
70
test/renderer/hooks/useMentionDetection.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue