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
0867966d17
commit
49cf6405a2
29 changed files with 1872 additions and 268 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
|
|
@ -100,6 +101,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
|
||||
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
|
||||
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
||||
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
|
@ -125,6 +127,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
|
||||
ipcMain.removeHandler(TEAM_START_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
||||
}
|
||||
|
||||
|
|
@ -531,6 +534,9 @@ async function handleCreateTask(
|
|||
return { success: false, error: 'prompt exceeds max length (5000)' };
|
||||
}
|
||||
}
|
||||
if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') {
|
||||
return { success: false, error: 'startImmediately must be a boolean' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('createTask', () =>
|
||||
getTeamDataService().createTask(validatedTeamName.value!, {
|
||||
|
|
@ -539,6 +545,7 @@ async function handleCreateTask(
|
|||
owner: payload.owner?.trim() || undefined,
|
||||
blockedBy: payload.blockedBy,
|
||||
prompt: payload.prompt?.trim() || undefined,
|
||||
startImmediately: payload.startImmediately,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -762,6 +769,24 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
|
|||
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
|
||||
}
|
||||
|
||||
async function handleStartTask(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const validatedTaskId = validateTaskId(taskId);
|
||||
if (!validatedTaskId.valid) {
|
||||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||||
}
|
||||
return wrapTeamHandler('startTask', () =>
|
||||
getTeamDataService().startTask(validatedTeamName.value!, validatedTaskId.value!)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
|
||||
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import * as path from 'path';
|
|||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
const TOOL_FILE_NAME = 'teamctl.js';
|
||||
const TOOL_VERSION = 3;
|
||||
const TOOL_VERSION = 4;
|
||||
|
||||
function buildTeamCtlScript(): string {
|
||||
const script = String.raw`#!/usr/bin/env node
|
||||
|
|
@ -395,7 +395,7 @@ function printHelp() {
|
|||
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
|
||||
' node teamctl.js task complete <id> [--team <team>]',
|
||||
' node teamctl.js task start <id> [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
||||
' node teamctl.js kanban clear <id> [--team <team>]',
|
||||
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
|
||||
|
|
@ -453,25 +453,26 @@ async function main() {
|
|||
if (notify && task.owner) {
|
||||
const from =
|
||||
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user';
|
||||
const text =
|
||||
'New task assigned to you: #' +
|
||||
String(task.id) +
|
||||
' "' +
|
||||
String(task.subject) +
|
||||
'".\n\n' +
|
||||
'When you start: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
|
||||
String(teamName) +
|
||||
' task start ' +
|
||||
String(task.id) +
|
||||
'\n' +
|
||||
'When done: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
|
||||
String(teamName) +
|
||||
' task complete ' +
|
||||
String(task.id) +
|
||||
'\n';
|
||||
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
|
||||
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
||||
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
|
||||
if (rawDesc && rawDesc !== task.subject) {
|
||||
parts.push('\nDescription:\n' + rawDesc);
|
||||
}
|
||||
const prompt = typeof args.flags.prompt === 'string' ? args.flags.prompt.trim() : '';
|
||||
if (prompt) {
|
||||
parts.push('\nInstructions:\n' + prompt);
|
||||
}
|
||||
parts.push(
|
||||
'\n${'```'}info_for_agent',
|
||||
'Update task status using:',
|
||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
|
||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
||||
'${'```'}'
|
||||
);
|
||||
sendInboxMessage(paths, teamName, {
|
||||
to: task.owner,
|
||||
text,
|
||||
text: parts.join('\n'),
|
||||
summary: 'New task #' + String(task.id) + ' assigned',
|
||||
from,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -216,12 +217,14 @@ export class TeamDataService {
|
|||
/* best-effort */
|
||||
}
|
||||
|
||||
const shouldStart = request.owner && request.startImmediately !== false;
|
||||
|
||||
const task: TeamTask = {
|
||||
id: nextId,
|
||||
subject: request.subject,
|
||||
description,
|
||||
owner: request.owner,
|
||||
status: request.owner ? 'in_progress' : 'pending',
|
||||
status: shouldStart ? 'in_progress' : 'pending',
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
projectPath,
|
||||
|
|
@ -234,18 +237,33 @@ export class TeamDataService {
|
|||
await this.taskWriter.addBlocksEntry(teamName, depId, nextId);
|
||||
}
|
||||
|
||||
if (request.owner) {
|
||||
if (shouldStart && request.owner) {
|
||||
try {
|
||||
const toolPath = await this.toolsInstaller.ensureInstalled();
|
||||
|
||||
// Build notification with full context — inbox is the primary delivery
|
||||
// channel to agents (Claude Code monitors inbox via fs.watch)
|
||||
const parts = [`New task assigned to you: #${task.id} "${task.subject}".`];
|
||||
|
||||
if (request.description?.trim()) {
|
||||
parts.push(`\nDescription:\n${request.description.trim()}`);
|
||||
}
|
||||
|
||||
if (request.prompt?.trim()) {
|
||||
parts.push(`\nInstructions:\n${request.prompt.trim()}`);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Update task status using:`,
|
||||
`node "${toolPath}" --team ${teamName} task start ${task.id}`,
|
||||
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
|
||||
AGENT_BLOCK_CLOSE
|
||||
);
|
||||
|
||||
await this.sendMessage(teamName, {
|
||||
member: request.owner,
|
||||
text:
|
||||
`New task assigned to you: #${task.id} "${task.subject}".\n\n` +
|
||||
`Update task status using:\n` +
|
||||
`node "${toolPath}" --team ${teamName} task start ${task.id}\n` +
|
||||
`node "${toolPath}" --team ${teamName} task complete ${task.id}\n\n` +
|
||||
`Help:\n` +
|
||||
`node "${toolPath}" --help`,
|
||||
text: parts.join('\n'),
|
||||
summary: `New task #${task.id} assigned`,
|
||||
});
|
||||
} catch {
|
||||
|
|
@ -256,6 +274,42 @@ export class TeamDataService {
|
|||
return task;
|
||||
}
|
||||
|
||||
async startTask(teamName: string, taskId: string): Promise<void> {
|
||||
const tasks = await this.taskReader.getTasks(teamName);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task #${taskId} not found`);
|
||||
}
|
||||
if (task.status !== 'pending') {
|
||||
throw new Error(`Task #${taskId} is not pending (current: ${task.status})`);
|
||||
}
|
||||
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
||||
|
||||
if (task.owner) {
|
||||
try {
|
||||
const toolPath = await this.toolsInstaller.ensureInstalled();
|
||||
const parts = [`Task #${task.id} "${task.subject}" has been started.`];
|
||||
if (task.description?.trim()) {
|
||||
parts.push(`\nDetails:\n${task.description.trim()}`);
|
||||
}
|
||||
parts.push(
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Update task status using:`,
|
||||
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
|
||||
AGENT_BLOCK_CLOSE
|
||||
);
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
text: parts.join('\n'),
|
||||
summary: `Task #${task.id} started`,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort notification
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, status);
|
||||
}
|
||||
|
|
@ -279,10 +333,12 @@ export class TeamDataService {
|
|||
member: reviewer,
|
||||
text:
|
||||
`Please review task #${taskId}.\n\n` +
|
||||
`${AGENT_BLOCK_OPEN}\n` +
|
||||
`When approved, move it to APPROVED:\n` +
|
||||
`node "${toolPath}" --team ${teamName} review approve ${taskId}\n\n` +
|
||||
`If changes are needed:\n` +
|
||||
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."`,
|
||||
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` +
|
||||
AGENT_BLOCK_CLOSE,
|
||||
summary: `Review request for #${taskId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||
import { createReadStream } from 'fs';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
|
@ -13,7 +14,18 @@ import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types';
|
|||
|
||||
const logger = createLogger('Service:TeamMemberLogsFinder');
|
||||
|
||||
const MAX_LINES_TO_SCAN = 30;
|
||||
/**
|
||||
* Phase 1: How many lines to scan for member attribution.
|
||||
* Detection signals (process.team.memberName, "You are {name}", routing.sender)
|
||||
* appear in the first ~10 lines, so 50 is very conservative.
|
||||
*/
|
||||
const ATTRIBUTION_SCAN_LINES = 50;
|
||||
|
||||
interface StreamedMetadata {
|
||||
firstTimestamp: string | null;
|
||||
lastTimestamp: string | null;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let end = value.length;
|
||||
|
|
@ -126,16 +138,11 @@ export class TeamMemberLogsFinder {
|
|||
if (file.startsWith('agent-acompact')) continue;
|
||||
|
||||
const filePath = path.join(subagentsDir, file);
|
||||
// Quick attribution check — reuse parseSubagentSummary to verify membership
|
||||
const summary = await this.parseSubagentSummary(
|
||||
filePath,
|
||||
projectId,
|
||||
sessionId,
|
||||
file,
|
||||
memberName,
|
||||
knownMembers
|
||||
);
|
||||
if (summary) paths.push(filePath);
|
||||
// Quick attribution check — only Phase 1 (no full-file streaming)
|
||||
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
|
||||
paths.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,21 +175,35 @@ export class TeamMemberLogsFinder {
|
|||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
|
||||
|
||||
let sessionIds: string[];
|
||||
// Collect all known session IDs: current lead + history
|
||||
const knownSessionIds = new Set<string>();
|
||||
if (config.leadSessionId) {
|
||||
const leadDir = path.join(projectDir, config.leadSessionId);
|
||||
try {
|
||||
const stat = await fs.stat(leadDir);
|
||||
if (stat.isDirectory()) {
|
||||
sessionIds = [config.leadSessionId];
|
||||
} else {
|
||||
logger.debug(`leadSessionId dir is not a directory: ${leadDir}`);
|
||||
sessionIds = await this.listSessionDirs(projectDir);
|
||||
knownSessionIds.add(config.leadSessionId);
|
||||
}
|
||||
if (Array.isArray(config.sessionHistory)) {
|
||||
for (const sid of config.sessionHistory) {
|
||||
if (typeof sid === 'string' && sid.trim().length > 0) {
|
||||
knownSessionIds.add(sid.trim());
|
||||
}
|
||||
} catch {
|
||||
logger.debug(`leadSessionId dir not found: ${leadDir}, falling back to full scan`);
|
||||
sessionIds = await this.listSessionDirs(projectDir);
|
||||
}
|
||||
}
|
||||
|
||||
let sessionIds: string[];
|
||||
if (knownSessionIds.size > 0) {
|
||||
// Verify each known session dir exists, fall back to full scan if none exist
|
||||
const verified: string[] = [];
|
||||
for (const sid of knownSessionIds) {
|
||||
const sidDir = path.join(projectDir, sid);
|
||||
try {
|
||||
const stat = await fs.stat(sidDir);
|
||||
if (stat.isDirectory()) {
|
||||
verified.push(sid);
|
||||
}
|
||||
} catch {
|
||||
// dir doesn't exist, skip
|
||||
}
|
||||
}
|
||||
sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir);
|
||||
} else {
|
||||
sessionIds = await this.listSessionDirs(projectDir);
|
||||
}
|
||||
|
|
@ -237,112 +258,28 @@ export class TeamMemberLogsFinder {
|
|||
knownMembers: Set<string>
|
||||
): Promise<MemberSubagentLogSummary | null> {
|
||||
const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
||||
const lines: string[] = [];
|
||||
|
||||
try {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
// ── Phase 1: Attribution (first N lines) ──
|
||||
// Detect which member owns this file + extract description.
|
||||
// All detection signals appear in the first few lines of the JSONL.
|
||||
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||
if (!attribution) return null;
|
||||
|
||||
let count = 0;
|
||||
for await (const line of rl) {
|
||||
if (count >= MAX_LINES_TO_SCAN) break;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
lines.push(trimmed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
let firstTimestamp: string | null = null;
|
||||
let lastTimestamp: string | null = null;
|
||||
let messageCount = 0;
|
||||
let description = '';
|
||||
const targetLower = targetMember.toLowerCase();
|
||||
|
||||
// Multi-signal member detection with priority levels:
|
||||
// 3 = routing sender (highest — directly identifies the agent)
|
||||
// 2 = "You are {name}" spawn prompt (high — reliable identification)
|
||||
// 1 = text-based fallback (low — may match wrong member from teammate_id etc.)
|
||||
let detectedMember: string | null = null;
|
||||
let detectionPriority = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line) as Record<string, unknown>;
|
||||
|
||||
const role = this.extractRole(msg);
|
||||
const textContent = this.extractTextContent(msg);
|
||||
|
||||
// Skip warmup messages
|
||||
if (role === 'user' && textContent?.trim() === 'Warmup') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track timestamps
|
||||
if (typeof msg.timestamp === 'string') {
|
||||
if (!firstTimestamp) firstTimestamp = msg.timestamp;
|
||||
lastTimestamp = msg.timestamp;
|
||||
}
|
||||
|
||||
messageCount++;
|
||||
|
||||
// Extract description from first user message
|
||||
if (role === 'user' && !description && textContent) {
|
||||
description = textContent.slice(0, 200);
|
||||
}
|
||||
|
||||
// --- Multi-signal member detection ---
|
||||
// Higher priority signals override lower priority ones
|
||||
const detection = this.detectMemberFromMessage(msg, knownMembers);
|
||||
if (detection && detection.priority > detectionPriority) {
|
||||
detectedMember = detection.name;
|
||||
detectionPriority = detection.priority;
|
||||
}
|
||||
|
||||
// Check toolUseResult routing (highest priority — directly identifies the agent)
|
||||
if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') {
|
||||
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (routing && typeof routing.sender === 'string') {
|
||||
const sender = routing.sender.toLowerCase();
|
||||
if (knownMembers.has(sender)) {
|
||||
detectedMember = routing.sender;
|
||||
detectionPriority = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
// Match: the detected member must match the target member
|
||||
if (detectedMember?.toLowerCase() !== targetLower) {
|
||||
if (attribution.detectedMember.toLowerCase() !== targetLower) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!firstTimestamp) {
|
||||
// Fallback: use file mtime
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
firstTimestamp = stat.mtime.toISOString();
|
||||
lastTimestamp = firstTimestamp;
|
||||
} catch {
|
||||
firstTimestamp = new Date().toISOString();
|
||||
lastTimestamp = firstTimestamp;
|
||||
}
|
||||
}
|
||||
// ── Phase 2: Metadata (stream entire file) ──
|
||||
// Now that we know the file belongs to this member, collect
|
||||
// accurate timestamps and message count from the full file.
|
||||
const metadata = await this.streamFileMetadata(filePath);
|
||||
|
||||
const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(filePath));
|
||||
const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
|
||||
|
||||
const startTime = new Date(firstTimestamp);
|
||||
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
|
||||
const endTime = new Date(lastTimestamp);
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// Check if the file might still be active (modified recently)
|
||||
|
|
@ -360,15 +297,135 @@ export class TeamMemberLogsFinder {
|
|||
subagentId,
|
||||
sessionId,
|
||||
projectId,
|
||||
description: description || `Subagent ${subagentId}`,
|
||||
description: attribution.description || `Subagent ${subagentId}`,
|
||||
memberName: targetMember,
|
||||
startTime: firstTimestamp,
|
||||
durationMs: Math.max(0, durationMs),
|
||||
messageCount,
|
||||
messageCount: metadata.messageCount,
|
||||
isOngoing,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals
|
||||
* and extract a human-readable description from the first user message.
|
||||
* Returns null if the file is a warmup session or empty.
|
||||
*/
|
||||
private async attributeSubagent(
|
||||
filePath: string,
|
||||
knownMembers: Set<string>
|
||||
): Promise<{ detectedMember: string; description: string } | null> {
|
||||
const lines: string[] = [];
|
||||
|
||||
try {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
let count = 0;
|
||||
for await (const line of rl) {
|
||||
if (count >= ATTRIBUTION_SCAN_LINES) break;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
lines.push(trimmed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
let description = '';
|
||||
let detectedMember: string | null = null;
|
||||
let detectionPriority = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Early exit: both objectives met (member detected at max priority + description found)
|
||||
if (detectionPriority >= 3 && description) break;
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(line) as Record<string, unknown>;
|
||||
|
||||
const role = this.extractRole(msg);
|
||||
const textContent = this.extractTextContent(msg);
|
||||
|
||||
// Skip warmup messages
|
||||
if (role === 'user' && textContent?.trim() === 'Warmup') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract description from first user message + teammate_id attribution
|
||||
if (role === 'user' && textContent) {
|
||||
if (textContent.trimStart().startsWith('<teammate-message')) {
|
||||
const parsed = parseAllTeammateMessages(textContent);
|
||||
if (!description) {
|
||||
description =
|
||||
parsed[0]?.summary || parsed[0]?.content?.slice(0, 200) || 'Teammate spawn';
|
||||
}
|
||||
|
||||
// teammate_id is a structured XML attribute — highest reliability signal
|
||||
if (detectionPriority < 3 && parsed[0]?.teammateId) {
|
||||
const tmId = parsed[0].teammateId.trim().toLowerCase();
|
||||
if (tmId.length > 0 && knownMembers.has(tmId)) {
|
||||
detectedMember = parsed[0].teammateId.trim();
|
||||
detectionPriority = 3;
|
||||
}
|
||||
}
|
||||
} else if (!description) {
|
||||
description = textContent.slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-signal member detection ---
|
||||
// Higher priority signals override lower priority ones (skip if already at max)
|
||||
if (detectionPriority < 3) {
|
||||
const detection = this.detectMemberFromMessage(msg, knownMembers);
|
||||
if (detection && detection.priority > detectionPriority) {
|
||||
detectedMember = detection.name;
|
||||
detectionPriority = detection.priority;
|
||||
}
|
||||
}
|
||||
|
||||
// Check toolUseResult routing (highest priority — directly identifies the agent)
|
||||
if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') {
|
||||
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (routing && typeof routing.sender === 'string') {
|
||||
const sender = routing.sender.toLowerCase();
|
||||
if (knownMembers.has(sender)) {
|
||||
detectedMember = routing.sender;
|
||||
detectionPriority = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check process.team.memberName from system messages (highest priority)
|
||||
if (detectionPriority < 3) {
|
||||
const init = msg.init as Record<string, unknown> | undefined;
|
||||
const process = (msg.process ?? init?.process) as Record<string, unknown> | undefined;
|
||||
const team = process?.team as Record<string, unknown> | undefined;
|
||||
if (team && typeof team.memberName === 'string') {
|
||||
const memberNameLower = team.memberName.trim().toLowerCase();
|
||||
if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) {
|
||||
detectedMember = team.memberName.trim();
|
||||
detectionPriority = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
if (!detectedMember) return null;
|
||||
|
||||
return { detectedMember, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the member name from a parsed JSONL message using multiple signals.
|
||||
* Returns a detection result with the name and a priority level:
|
||||
|
|
@ -463,49 +520,13 @@ export class TeamMemberLogsFinder {
|
|||
return null;
|
||||
}
|
||||
|
||||
let firstTimestamp: string | null = null;
|
||||
let lastTimestamp: string | null = null;
|
||||
let messageCount = 0;
|
||||
const metadata = await this.streamFileMetadata(jsonlPath);
|
||||
|
||||
try {
|
||||
const stream = createReadStream(jsonlPath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
let count = 0;
|
||||
for await (const line of rl) {
|
||||
if (count >= MAX_LINES_TO_SCAN) break;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
count++;
|
||||
messageCount++;
|
||||
try {
|
||||
const msg = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (typeof msg.timestamp === 'string') {
|
||||
if (!firstTimestamp) firstTimestamp = msg.timestamp;
|
||||
lastTimestamp = msg.timestamp;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!firstTimestamp) {
|
||||
try {
|
||||
const stat = await fs.stat(jsonlPath);
|
||||
firstTimestamp = stat.mtime.toISOString();
|
||||
lastTimestamp = firstTimestamp;
|
||||
} catch {
|
||||
firstTimestamp = new Date().toISOString();
|
||||
lastTimestamp = firstTimestamp;
|
||||
}
|
||||
}
|
||||
const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(jsonlPath));
|
||||
const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
|
||||
|
||||
const startTime = new Date(firstTimestamp);
|
||||
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
|
||||
const endTime = new Date(lastTimestamp);
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
let isOngoing = false;
|
||||
|
|
@ -525,10 +546,55 @@ export class TeamMemberLogsFinder {
|
|||
memberName,
|
||||
startTime: firstTimestamp,
|
||||
durationMs: Math.max(0, durationMs),
|
||||
messageCount,
|
||||
messageCount: metadata.messageCount,
|
||||
isOngoing,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream entire JSONL file collecting only timestamps and message count.
|
||||
* Lightweight — uses regex to extract timestamp without full JSON parse.
|
||||
*/
|
||||
private async streamFileMetadata(filePath: string): Promise<StreamedMetadata> {
|
||||
let firstTimestamp: string | null = null;
|
||||
let lastTimestamp: string | null = null;
|
||||
let messageCount = 0;
|
||||
|
||||
try {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
messageCount++;
|
||||
|
||||
// Fast timestamp extraction without full JSON parse.
|
||||
// ISO prefix anchor avoids false positives from "timestamp" inside string values.
|
||||
const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(trimmed);
|
||||
if (tsMatch) {
|
||||
if (!firstTimestamp) firstTimestamp = tsMatch[1];
|
||||
lastTimestamp = tsMatch[1];
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// ignore — return whatever we collected so far
|
||||
}
|
||||
|
||||
return { firstTimestamp, lastTimestamp, messageCount };
|
||||
}
|
||||
|
||||
private async getFileMtime(filePath: string): Promise<string> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.mtime.toISOString();
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findOriginalCase(text: string, lowerName: string): string {
|
||||
|
|
|
|||
|
|
@ -242,5 +242,8 @@ export const TEAM_UPDATE_CONFIG = 'team:updateConfig';
|
|||
/** Get aggregated member stats */
|
||||
export const TEAM_GET_MEMBER_STATS = 'team:getMemberStats';
|
||||
|
||||
/** Start a pending task (transition to in_progress + notify agent) */
|
||||
export const TEAM_START_TASK = 'team:startTask';
|
||||
|
||||
/** Get all tasks across all teams */
|
||||
export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
|
|
@ -205,8 +206,7 @@ const electronAPI: ElectronAPI = {
|
|||
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
|
||||
|
||||
// Agent config reading
|
||||
readAgentConfigs: (projectRoot: string) =>
|
||||
ipcRenderer.invoke('read-agent-configs', projectRoot),
|
||||
readAgentConfigs: (projectRoot: string) => ipcRenderer.invoke('read-agent-configs', projectRoot),
|
||||
|
||||
// Notifications API
|
||||
notifications: {
|
||||
|
|
@ -540,6 +540,9 @@ const electronAPI: ElectronAPI = {
|
|||
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
||||
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
|
||||
},
|
||||
startTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
|
||||
},
|
||||
processSend: async (teamName: string, message: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -668,6 +668,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<void> => {
|
||||
throw new Error('Team task status update is not available in browser mode');
|
||||
},
|
||||
startTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||
throw new Error('Team start task is not available in browser mode');
|
||||
},
|
||||
processSend: async (_teamName: string, _message: string): Promise<void> => {
|
||||
throw new Error('Team process communication is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -78,12 +78,14 @@ export const GlobalTaskList = (): React.JSX.Element => {
|
|||
const [filter, setFilter] = useState<StatusFilter>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const hasFetchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalTasks.length === 0 && !globalTasksLoading) {
|
||||
if (!hasFetchedRef.current) {
|
||||
hasFetchedRef.current = true;
|
||||
void fetchAllTasks();
|
||||
}
|
||||
}, [globalTasks.length, globalTasksLoading, fetchAllTasks]);
|
||||
}, [fetchAllTasks]);
|
||||
|
||||
const selectedProjectPath = useMemo(() => {
|
||||
if (viewMode === 'grouped') {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
|||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||||
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
||||
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||
|
|
@ -58,6 +59,7 @@ function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] {
|
|||
|
||||
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
|
||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||
open: false,
|
||||
|
|
@ -91,6 +93,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sendTeamMessage,
|
||||
requestReview,
|
||||
createTeamTask,
|
||||
startTask,
|
||||
deleteTeam,
|
||||
openTeamsTab,
|
||||
sendingMessage,
|
||||
|
|
@ -113,6 +116,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sendTeamMessage: s.sendTeamMessage,
|
||||
requestReview: s.requestReview,
|
||||
createTeamTask: s.createTeamTask,
|
||||
startTask: s.startTask,
|
||||
deleteTeam: s.deleteTeam,
|
||||
openTeamsTab: s.openTeamsTab,
|
||||
sendingMessage: s.sendingMessage,
|
||||
|
|
@ -257,6 +261,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return filterKanbanTasks(filteredTasks, query);
|
||||
}, [filteredTasks, kanbanSearch]);
|
||||
|
||||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||
|
||||
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
|
||||
setCreateTaskDialog({
|
||||
open: true,
|
||||
|
|
@ -297,7 +303,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
description: string,
|
||||
owner?: string,
|
||||
blockedBy?: string[],
|
||||
prompt?: string
|
||||
prompt?: string,
|
||||
startImmediately?: boolean
|
||||
): void => {
|
||||
setCreatingTask(true);
|
||||
void (async () => {
|
||||
|
|
@ -308,9 +315,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
owner,
|
||||
blockedBy,
|
||||
prompt,
|
||||
startImmediately,
|
||||
});
|
||||
|
||||
if (prompt && owner && data?.isAlive) {
|
||||
if (prompt && owner && data?.isAlive && startImmediately !== false) {
|
||||
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
|
||||
try {
|
||||
await api.teams.processSend(teamName, msg);
|
||||
|
|
@ -534,6 +542,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onMoveBackToDone={(taskId) => {
|
||||
void updateKanban(teamName, taskId, { op: 'remove' });
|
||||
}}
|
||||
onStartTask={(taskId) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await startTask(teamName, taskId);
|
||||
if (data?.isAlive) {
|
||||
const task = data.tasks.find((t) => t.id === taskId);
|
||||
if (task?.owner) {
|
||||
try {
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task #${taskId} "${task.subject}" has started. Please begin working on it.`
|
||||
);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onCompleteTask={(taskId) => {
|
||||
void updateTaskStatus(teamName, taskId, 'completed');
|
||||
}}
|
||||
|
|
@ -545,6 +575,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
|
||||
}
|
||||
}}
|
||||
onTaskClick={(task) => setSelectedTask(task)}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
@ -664,6 +695,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}}
|
||||
onClose={() => setSendDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<TaskDetailDialog
|
||||
open={selectedTask !== null}
|
||||
task={selectedTask}
|
||||
teamName={teamName}
|
||||
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
|
||||
taskMap={taskMap}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onScrollToTask={(taskId) => {
|
||||
setSelectedTask(null);
|
||||
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
el.classList.add('ring-2', 'ring-blue-400/50');
|
||||
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import {
|
||||
CARD_BG,
|
||||
|
|
@ -12,7 +14,8 @@ import {
|
|||
parseStructuredAgentMessage,
|
||||
} from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { Bot, ListPlus, MessageSquare } from 'lucide-react';
|
||||
import { AGENT_BLOCK_REGEX } from '@shared/constants/agentBlocks';
|
||||
import { Bot, ChevronRight, ListPlus, MessageSquare } from 'lucide-react';
|
||||
|
||||
import type { TeamColorSet } from '@renderer/constants/teamColors';
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
|
@ -86,7 +89,31 @@ const NoiseRow = ({
|
|||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full message card — left colored border, name badge, expanded content
|
||||
// Detect system/automated messages that should be collapsed by default.
|
||||
// These are generated by teamctl.js and contain tool instructions, not
|
||||
// human-written content, so showing them expanded adds visual noise.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
|
||||
{ pattern: /^New task assigned to you:/, label: 'Task assignment' },
|
||||
{ pattern: /^Task #\d+\s+approved/, label: 'Task approved' },
|
||||
{ pattern: /^Task #\d+\s+needs fixes/, label: 'Review changes requested' },
|
||||
];
|
||||
|
||||
function getSystemMessageLabel(text: string): string | null {
|
||||
for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) {
|
||||
if (pattern.test(text)) return label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strip ```info_for_agent ... ``` blocks from text for UI display. */
|
||||
function stripAgentBlocks(text: string): string {
|
||||
return text.replace(AGENT_BLOCK_REGEX, '').trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full message card — left colored border, name badge, collapsible content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ActivityItem = ({
|
||||
|
|
@ -105,6 +132,16 @@ export const ActivityItem = ({
|
|||
const structured = parseStructuredAgentMessage(message.text);
|
||||
const noiseLabel = structured ? getNoiseLabel(structured) : null;
|
||||
|
||||
// System/automated messages start collapsed
|
||||
const systemLabel = !structured ? getSystemMessageLabel(message.text) : null;
|
||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||
|
||||
// Strip agent-only blocks from displayed text
|
||||
const displayText = useMemo(
|
||||
() => (structured ? null : stripAgentBlocks(message.text)),
|
||||
[structured, message.text]
|
||||
);
|
||||
|
||||
// Noise messages: minimal inline row
|
||||
if (noiseLabel) {
|
||||
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
|
||||
|
|
@ -132,8 +169,37 @@ export const ActivityItem = ({
|
|||
borderLeft: `3px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
{/* Header — clickable when system message to toggle expand */}
|
||||
<div
|
||||
className={[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
systemLabel ? 'cursor-pointer select-none' : '',
|
||||
].join(' ')}
|
||||
role={systemLabel ? 'button' : undefined}
|
||||
tabIndex={systemLabel ? 0 : undefined}
|
||||
onClick={systemLabel ? () => setIsExpanded((v) => !v) : undefined}
|
||||
onKeyDown={
|
||||
systemLabel
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsExpanded((v) => !v);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Chevron for collapsible system messages */}
|
||||
{systemLabel ? (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 transition-transform duration-150"
|
||||
style={{
|
||||
color: CARD_ICON_MUTED,
|
||||
transform: isExpanded ? 'rotate(90deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.source === 'lead_session' ? (
|
||||
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||
) : (
|
||||
|
|
@ -159,8 +225,12 @@ export const ActivityItem = ({
|
|||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Message type label */}
|
||||
{messageType ? (
|
||||
{/* Message type label or system label */}
|
||||
{systemLabel ? (
|
||||
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
{systemLabel}
|
||||
</span>
|
||||
) : messageType ? (
|
||||
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
{messageType}
|
||||
</span>
|
||||
|
|
@ -193,7 +263,10 @@ export const ActivityItem = ({
|
|||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
title="Create task from message"
|
||||
onClick={handleCreateTask}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCreateTask();
|
||||
}}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
|
|
@ -204,26 +277,28 @@ export const ActivityItem = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content — always expanded */}
|
||||
<div className="px-3 pb-3">
|
||||
{structured ? (
|
||||
<div className="space-y-2">
|
||||
{autoSummary && autoSummary !== messageType ? (
|
||||
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
|
||||
) : null}
|
||||
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
Raw JSON
|
||||
</summary>
|
||||
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
{JSON.stringify(structured, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownViewer content={message.text} maxHeight="max-h-56" copyable />
|
||||
)}
|
||||
</div>
|
||||
{/* Content — collapsed for system messages, expanded for others */}
|
||||
{isExpanded ? (
|
||||
<div className="px-3 pb-3">
|
||||
{structured ? (
|
||||
<div className="space-y-2">
|
||||
{autoSummary && autoSummary !== messageType ? (
|
||||
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
|
||||
) : null}
|
||||
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
Raw JSON
|
||||
</summary>
|
||||
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
{JSON.stringify(structured, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -20,9 +21,11 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
|
|
@ -38,7 +41,8 @@ interface CreateTaskDialogProps {
|
|||
description: string,
|
||||
owner?: string,
|
||||
blockedBy?: string[],
|
||||
prompt?: string
|
||||
prompt?: string,
|
||||
startImmediately?: boolean
|
||||
) => void;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
|
@ -61,6 +65,7 @@ export const CreateTaskDialog = ({
|
|||
});
|
||||
const [owner, setOwner] = useState<string>(defaultOwner);
|
||||
const [blockedBy, setBlockedBy] = useState<string[]>([]);
|
||||
const [startImmediately, setStartImmediately] = useState(true);
|
||||
const promptDraft = useDraftPersistence({ key: 'createTask:prompt' });
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
|
||||
|
|
@ -71,12 +76,24 @@ export const CreateTaskDialog = ({
|
|||
}
|
||||
setOwner(defaultOwner);
|
||||
setBlockedBy([]);
|
||||
setStartImmediately(true);
|
||||
promptDraft.clearDraft();
|
||||
}
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
}
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const canSubmit = subject.trim().length > 0 && !submitting;
|
||||
|
||||
// Only show non-internal, non-deleted tasks as candidates for blocking
|
||||
|
|
@ -95,7 +112,8 @@ export const CreateTaskDialog = ({
|
|||
descriptionDraft.value.trim(),
|
||||
owner || undefined,
|
||||
blockedBy.length > 0 ? blockedBy : undefined,
|
||||
promptDraft.value.trim() || undefined
|
||||
promptDraft.value.trim() || undefined,
|
||||
startImmediately
|
||||
);
|
||||
descriptionDraft.clearDraft();
|
||||
promptDraft.clearDraft();
|
||||
|
|
@ -135,32 +153,38 @@ export const CreateTaskDialog = ({
|
|||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="task-description">Description (optional)</Label>
|
||||
<AutoResizeTextarea
|
||||
<MentionableTextarea
|
||||
id="task-description"
|
||||
placeholder="Task details..."
|
||||
value={descriptionDraft.value}
|
||||
onValueChange={descriptionDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
onChange={(e) => descriptionDraft.setValue(e.target.value)}
|
||||
footerRight={
|
||||
descriptionDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{descriptionDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label>
|
||||
<AutoResizeTextarea
|
||||
<MentionableTextarea
|
||||
id="task-prompt"
|
||||
placeholder="Custom instructions for the team member..."
|
||||
value={promptDraft.value}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
onChange={(e) => promptDraft.setValue(e.target.value)}
|
||||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -175,11 +199,24 @@ export const CreateTaskDialog = ({
|
|||
<SelectContent>
|
||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.agentType);
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const memberColor = m.color ? getTeamColorSet(m.color) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
{m.name}
|
||||
{role ? ` (${role})` : ''}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{memberColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: memberColor.border }}
|
||||
/>
|
||||
) : null}
|
||||
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
||||
{m.name}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="text-[var(--color-text-muted)]">({role})</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -187,6 +224,19 @@ export const CreateTaskDialog = ({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{owner ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="task-start-immediately"
|
||||
checked={startImmediately}
|
||||
onCheckedChange={(v) => setStartImmediately(v === true)}
|
||||
/>
|
||||
<Label htmlFor="task-start-immediately" className="text-xs font-normal">
|
||||
Start immediately
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableTasks.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Blocked by tasks (optional)</Label>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -39,6 +40,7 @@ const TEAM_COLOR_NAMES = [
|
|||
'pink',
|
||||
] as const;
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type {
|
||||
Project,
|
||||
TeamCreateRequest,
|
||||
|
|
@ -426,6 +428,24 @@ export const CreateTeamDialog = ({
|
|||
const description = descriptionDraft.value;
|
||||
const prompt = promptDraft.value;
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members
|
||||
.filter((m) => m.name.trim())
|
||||
.map((m, index) => ({
|
||||
id: m.id,
|
||||
name: m.name.trim(),
|
||||
subtitle:
|
||||
m.roleSelection === CUSTOM_ROLE
|
||||
? m.customRole.trim() || undefined
|
||||
: m.roleSelection && m.roleSelection !== NO_ROLE
|
||||
? m.roleSelection
|
||||
: undefined,
|
||||
color: getMemberColor(index),
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const request = useMemo<TeamCreateRequest>(
|
||||
() => ({
|
||||
teamName: teamName.trim(),
|
||||
|
|
@ -744,18 +764,21 @@ export const CreateTeamDialog = ({
|
|||
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
|
||||
Prompt for team lead (optional)
|
||||
</Label>
|
||||
<AutoResizeTextarea
|
||||
<MentionableTextarea
|
||||
id="team-prompt"
|
||||
className="text-xs"
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
value={prompt}
|
||||
onChange={(event) => promptDraft.setValue(event.target.value)}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
placeholder="Instructions for the team lead during provisioning..."
|
||||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -12,6 +11,7 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -19,9 +19,11 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
|
||||
interface SendMessageDialogProps {
|
||||
|
|
@ -72,6 +74,17 @@ export const SendMessageDialog = ({
|
|||
onClose();
|
||||
}
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: m.color,
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
|
|
@ -107,11 +120,24 @@ export const SendMessageDialog = ({
|
|||
<SelectContent>
|
||||
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.agentType);
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const memberColor = m.color ? getTeamColorSet(m.color) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
{m.name}
|
||||
{role ? ` (${role})` : ''}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{memberColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: memberColor.border }}
|
||||
/>
|
||||
) : null}
|
||||
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
||||
{m.name}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="text-[var(--color-text-muted)]">({role})</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -131,17 +157,20 @@ export const SendMessageDialog = ({
|
|||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="smd-message">Message</Label>
|
||||
<AutoResizeTextarea
|
||||
<MentionableTextarea
|
||||
id="smd-message"
|
||||
placeholder="Write your message..."
|
||||
value={textDraft.value}
|
||||
onValueChange={textDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
onChange={(e) => textDraft.setValue(e.target.value)}
|
||||
footerRight={
|
||||
textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
|
||||
|
|
|
|||
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;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
onMoveBackToDone: (taskId: string) => void;
|
||||
onStartTask: (taskId: string) => void;
|
||||
onCompleteTask: (taskId: string) => void;
|
||||
onScrollToTask?: (taskId: string) => void;
|
||||
onTaskClick?: (task: TeamTask) => void;
|
||||
}
|
||||
|
||||
type KanbanViewMode = 'grid' | 'columns';
|
||||
|
|
@ -68,8 +70,10 @@ export const KanbanBoard = ({
|
|||
onApprove,
|
||||
onRequestChanges,
|
||||
onMoveBackToDone,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
}: KanbanBoardProps): React.JSX.Element => {
|
||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||
|
||||
|
|
@ -110,8 +114,10 @@ export const KanbanBoard = ({
|
|||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
onMoveBackToDone={onMoveBackToDone}
|
||||
onStartTask={onStartTask}
|
||||
onCompleteTask={onCompleteTask}
|
||||
onScrollToTask={onScrollToTask}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2 } from 'lucide-react';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
|
||||
|
||||
import { ReviewBadge } from './ReviewBadge';
|
||||
|
||||
|
|
@ -16,8 +16,10 @@ interface KanbanTaskCardProps {
|
|||
onApprove: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
onMoveBackToDone: (taskId: string) => void;
|
||||
onStartTask: (taskId: string) => void;
|
||||
onCompleteTask: (taskId: string) => void;
|
||||
onScrollToTask?: (taskId: string) => void;
|
||||
onTaskClick?: (task: TeamTask) => void;
|
||||
}
|
||||
|
||||
interface DependencyBadgeProps {
|
||||
|
|
@ -63,8 +65,10 @@ export const KanbanTaskCard = ({
|
|||
onApprove,
|
||||
onRequestChanges,
|
||||
onMoveBackToDone,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
}: KanbanTaskCardProps): React.JSX.Element => {
|
||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
|
|
@ -72,13 +76,22 @@ export const KanbanTaskCard = ({
|
|||
const hasBlocks = blocksIds.length > 0;
|
||||
|
||||
return (
|
||||
<article
|
||||
<div
|
||||
data-task-id={task.id}
|
||||
className={`rounded-md border p-3 ${
|
||||
className={`cursor-pointer rounded-md border p-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
hasBlockedBy
|
||||
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTaskClick?.(task);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
|
|
@ -126,13 +139,47 @@ export const KanbanTaskCard = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{columnId === 'todo' || columnId === 'in_progress' ? (
|
||||
{columnId === 'todo' ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
aria-label={`Start task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartTask(task.id);
|
||||
}}
|
||||
>
|
||||
<Play size={12} />
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
aria-label={`Complete task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{columnId === 'in_progress' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
aria-label={`Complete task ${task.id}`}
|
||||
onClick={() => onCompleteTask(task.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
Complete
|
||||
|
|
@ -144,7 +191,10 @@ export const KanbanTaskCard = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Request review for task ${task.id}`}
|
||||
onClick={() => onRequestReview(task.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestReview(task.id);
|
||||
}}
|
||||
>
|
||||
Request Review
|
||||
</Button>
|
||||
|
|
@ -160,7 +210,10 @@ export const KanbanTaskCard = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Approve task ${task.id}`}
|
||||
onClick={() => onApprove(task.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprove(task.id);
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
|
|
@ -168,7 +221,10 @@ export const KanbanTaskCard = ({
|
|||
variant="destructive"
|
||||
size="sm"
|
||||
aria-label={`Request changes for task ${task.id}`}
|
||||
onClick={() => onRequestChanges(task.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestChanges(task.id);
|
||||
}}
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
|
|
@ -181,11 +237,14 @@ export const KanbanTaskCard = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Move task ${task.id} back to done`}
|
||||
onClick={() => onMoveBackToDone(task.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveBackToDone(task.id);
|
||||
}}
|
||||
>
|
||||
Move back to DONE
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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
|
||||
id={listboxId}
|
||||
className="max-h-72 overflow-y-auto overscroll-contain p-1"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
|
||||
{emptyMessage}
|
||||
|
|
|
|||
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>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||
startTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
||||
deleteTeam: (teamName: string) => Promise<void>;
|
||||
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
||||
|
|
@ -361,6 +362,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
return task;
|
||||
},
|
||||
|
||||
startTask: async (teamName: string, taskId: string) => {
|
||||
await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
|
||||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
||||
await unwrapIpc('team:updateTaskStatus', () =>
|
||||
api.teams.updateTaskStatus(teamName, taskId, status)
|
||||
|
|
|
|||
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.
|
||||
*/
|
||||
|
||||
export * from './agentBlocks';
|
||||
export * from './cache';
|
||||
export * from './memberColors';
|
||||
export * from './trafficLights';
|
||||
|
|
|
|||
|
|
@ -354,6 +354,7 @@ export interface TeamsAPI {
|
|||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
||||
startTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
processSend: (teamName: string, message: string) => Promise<void>;
|
||||
processAlive: (teamName: string) => Promise<boolean>;
|
||||
aliveList: () => Promise<string[]>;
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export interface CreateTaskRequest {
|
|||
owner?: string;
|
||||
blockedBy?: string[];
|
||||
prompt?: string;
|
||||
startImmediately?: boolean;
|
||||
}
|
||||
|
||||
export interface TeamChangeEvent {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
|
||||
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
|
||||
TEAM_UPDATE_CONFIG: 'team:updateConfig',
|
||||
TEAM_START_TASK: 'team:startTask',
|
||||
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
|
||||
}));
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ import {
|
|||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_START_TASK,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
|
|
@ -72,6 +74,7 @@ describe('ipc teams handlers', () => {
|
|||
requestReview: vi.fn(async () => undefined),
|
||||
updateKanban: vi.fn(async () => undefined),
|
||||
updateTaskStatus: vi.fn(async () => undefined),
|
||||
startTask: vi.fn(async () => undefined),
|
||||
};
|
||||
const provisioningService = {
|
||||
prepareForProvisioning: vi.fn(async () => ({
|
||||
|
|
@ -115,6 +118,7 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
|
||||
expect(handlers.has(TEAM_START_TASK)).toBe(true);
|
||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
||||
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
|
||||
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
|
||||
|
|
@ -196,6 +200,7 @@ describe('ipc teams handlers', () => {
|
|||
owner: undefined,
|
||||
blockedBy: undefined,
|
||||
prompt: 'Custom instructions here',
|
||||
startImmediately: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -231,6 +236,7 @@ describe('ipc teams handlers', () => {
|
|||
owner: undefined,
|
||||
blockedBy: undefined,
|
||||
prompt: undefined,
|
||||
startImmediately: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -277,6 +283,7 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
|
||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
|
||||
expect(handlers.has(TEAM_START_TASK)).toBe(false);
|
||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
||||
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
|
||||
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -96,4 +96,153 @@ describe('TeamMemberLogsFinder', () => {
|
|||
expect(lead?.sessionId).toBe(leadSessionId);
|
||||
expect(lead?.projectId).toBe(projectId);
|
||||
});
|
||||
|
||||
it('detects member via teammate_id attribute in <teammate-message> tag', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
||||
const teamName = 't2';
|
||||
const projectPath = '/Users/test/proj2';
|
||||
const projectId = '-Users-test-proj2';
|
||||
const leadSessionId = 's2';
|
||||
|
||||
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'teams', teamName, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath,
|
||||
leadSessionId,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'general-purpose' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const projectRoot = path.join(tmpDir, 'projects', projectId);
|
||||
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
|
||||
|
||||
// Lead session file
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, `${leadSessionId}.jsonl`),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'Start' },
|
||||
}) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Subagent file using <teammate-message> format (no "You are" pattern)
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, leadSessionId, 'subagents', 'agent-xyz789.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'<teammate-message teammate_id="alice" color="green" summary="Implement feature X">Please implement the login page</teammate-message>',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:05.000Z',
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Working on it' }] },
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const finder = new TeamMemberLogsFinder();
|
||||
const aliceLogs = await finder.findMemberLogs(teamName, 'alice');
|
||||
|
||||
expect(aliceLogs).toHaveLength(1);
|
||||
expect(aliceLogs[0]?.kind).toBe('subagent');
|
||||
if (aliceLogs[0]?.kind === 'subagent') {
|
||||
expect(aliceLogs[0].subagentId).toBe('xyz789');
|
||||
expect(aliceLogs[0].description).toBe('Implement feature X');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports accurate messageCount from full file (not limited by scan lines)', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
||||
const teamName = 't3';
|
||||
const projectPath = '/Users/test/proj3';
|
||||
const projectId = '-Users-test-proj3';
|
||||
const leadSessionId = 's3';
|
||||
|
||||
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'teams', teamName, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath,
|
||||
leadSessionId,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'carol', agentType: 'general-purpose' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const projectRoot = path.join(tmpDir, 'projects', projectId);
|
||||
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, `${leadSessionId}.jsonl`),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'Go' },
|
||||
}) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Build a 200-line subagent file — well beyond ATTRIBUTION_SCAN_LINES (50)
|
||||
const lines: string[] = [];
|
||||
// First line: spawn prompt with teammate_id
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'<teammate-message teammate_id="carol" color="yellow" summary="Big task">Do 200 things</teammate-message>',
|
||||
},
|
||||
})
|
||||
);
|
||||
// Lines 2-200: alternating assistant/user messages
|
||||
for (let i = 2; i <= 200; i++) {
|
||||
const role = i % 2 === 0 ? 'assistant' : 'user';
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
timestamp: `2026-01-01T00:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}.000Z`,
|
||||
type: role,
|
||||
message: { role, content: `Message ${i}` },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, leadSessionId, 'subagents', 'agent-big123.jsonl'),
|
||||
lines.join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const finder = new TeamMemberLogsFinder();
|
||||
const carolLogs = await finder.findMemberLogs(teamName, 'carol');
|
||||
|
||||
expect(carolLogs).toHaveLength(1);
|
||||
expect(carolLogs[0]?.kind).toBe('subagent');
|
||||
// Full file has 200 messages — must NOT be capped at 50 or 100
|
||||
expect(carolLogs[0]?.messageCount).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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