From 49cf6405a21dee62df2ce5db9e3f331a123d955f Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 22 Feb 2026 18:32:30 +0200 Subject: [PATCH] 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. --- src/main/ipc/teams.ts | 25 ++ .../services/team/TeamAgentToolsInstaller.ts | 39 +- src/main/services/team/TeamDataService.ts | 76 +++- .../services/team/TeamMemberLogsFinder.ts | 394 ++++++++++-------- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 7 +- src/renderer/api/httpClient.ts | 3 + .../components/sidebar/GlobalTaskList.tsx | 6 +- .../components/team/TeamDetailView.tsx | 53 ++- .../components/team/activity/ActivityItem.tsx | 129 ++++-- .../team/dialogs/CreateTaskDialog.tsx | 84 +++- .../team/dialogs/CreateTeamDialog.tsx | 33 +- .../team/dialogs/SendMessageDialog.tsx | 49 ++- .../team/dialogs/TaskDetailDialog.tsx | 214 ++++++++++ .../components/team/kanban/KanbanBoard.tsx | 6 + .../components/team/kanban/KanbanTaskCard.tsx | 79 +++- .../components/ui/MentionSuggestionList.tsx | 99 +++++ .../components/ui/MentionableTextarea.tsx | 275 ++++++++++++ src/renderer/components/ui/combobox.tsx | 1 + src/renderer/hooks/useMentionDetection.ts | 298 +++++++++++++ src/renderer/store/slices/teamSlice.ts | 6 + src/renderer/types/mention.ts | 10 + src/shared/constants/agentBlocks.ts | 22 + src/shared/constants/index.ts | 1 + src/shared/types/api.ts | 1 + src/shared/types/team.ts | 1 + test/main/ipc/teams.test.ts | 7 + .../team/TeamMemberLogsFinder.test.ts | 149 +++++++ .../hooks/useMentionDetection.test.ts | 70 ++++ 29 files changed, 1872 insertions(+), 268 deletions(-) create mode 100644 src/renderer/components/team/dialogs/TaskDetailDialog.tsx create mode 100644 src/renderer/components/ui/MentionSuggestionList.tsx create mode 100644 src/renderer/components/ui/MentionableTextarea.tsx create mode 100644 src/renderer/hooks/useMentionDetection.ts create mode 100644 src/renderer/types/mention.ts create mode 100644 src/shared/constants/agentBlocks.ts create mode 100644 test/renderer/hooks/useMentionDetection.test.ts diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 766bd8f8..c3fa25d3 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 getTeamProvisioningService().getAliveTeams()); } +async function handleStartTask( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + 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> { return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks()); } diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 4ae804be..f7644717 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -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 [--team ]', ' node teamctl.js task complete [--team ]', ' node teamctl.js task start [--team ]', - ' node teamctl.js task create --subject "..." [--description "..."] [--owner "member"] [--notify --from "member"] [--team ]', + ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team ]', ' node teamctl.js kanban set-column [--team ]', ' node teamctl.js kanban clear [--team ]', ' node teamctl.js review approve [--notify-owner --from "member" --note "..."] [--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, }); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index d89948da..9715accd 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 { + 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 { 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) { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index daeaa7bf..144e4a57 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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(); 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 ): Promise { 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; - - 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).routing as - | Record - | 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 + ): 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; + + 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(' 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).routing as + | Record + | 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 | undefined; + const process = (msg.process ?? init?.process) as Record | undefined; + const team = process?.team as Record | 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; - 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 { + 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 { + try { + const stat = await fs.stat(filePath); + return stat.mtime.toISOString(); + } catch { + return new Date().toISOString(); + } + } } function findOriginalCase(text: string, lowerName: string): string { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 01f44e23..ad82f964 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 2cc4720a..1a4ddcbe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status); }, + startTask: async (teamName: string, taskId: string) => { + return invokeIpcWithResult(TEAM_START_TASK, teamName, taskId); + }, processSend: async (teamName: string, message: string) => { return invokeIpcWithResult(TEAM_PROCESS_SEND, teamName, message); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index fa2fdfa7..b59c5deb 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -668,6 +668,9 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { throw new Error('Team task status update is not available in browser mode'); }, + startTask: async (_teamName: string, _taskId: string): Promise => { + throw new Error('Team start task is not available in browser mode'); + }, processSend: async (_teamName: string, _message: string): Promise => { throw new Error('Team process communication is not available in browser mode'); }, diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index f5e91817..f9d8b428 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -78,12 +78,14 @@ export const GlobalTaskList = (): React.JSX.Element => { const [filter, setFilter] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const searchInputRef = useRef(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') { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 025b7995..27c98d41 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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(null); + const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const [createTaskDialog, setCreateTaskDialog] = useState({ 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)} /> @@ -664,6 +695,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }} onClose={() => setSendDialogOpen(false)} /> + + 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); + } + }} + /> ); }; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2593cb6b..2b7a8606 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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 ; @@ -132,8 +169,37 @@ export const ActivityItem = ({ borderLeft: `3px solid ${colors.border}`, }} > - {/* Header */} -
+ {/* Header — clickable when system message to toggle expand */} +
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 ? ( + + ) : null} + {message.source === 'lead_session' ? ( ) : ( @@ -159,8 +225,12 @@ export const ActivityItem = ({ ) : null} - {/* Message type label */} - {messageType ? ( + {/* Message type label or system label */} + {systemLabel ? ( + + {systemLabel} + + ) : messageType ? ( {messageType} @@ -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(); + }} > @@ -204,26 +277,28 @@ export const ActivityItem = ({
- {/* Content — always expanded */} -
- {structured ? ( -
- {autoSummary && autoSummary !== messageType ? ( -

{autoSummary}

- ) : null} -
- - Raw JSON - -
-                {JSON.stringify(structured, null, 2)}
-              
-
-
- ) : ( - - )} -
+ {/* Content — collapsed for system messages, expanded for others */} + {isExpanded ? ( +
+ {structured ? ( +
+ {autoSummary && autoSummary !== messageType ? ( +

{autoSummary}

+ ) : null} +
+ + Raw JSON + +
+                  {JSON.stringify(structured, null, 2)}
+                
+
+
+ ) : ( + + )} +
+ ) : null} ); }; diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index f3ed53f3..320c9138 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -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(defaultOwner); const [blockedBy, setBlockedBy] = useState([]); + 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( + () => + 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 = ({
- descriptionDraft.setValue(e.target.value)} + footerRight={ + descriptionDraft.isSaved ? ( + Draft saved + ) : null + } /> - {descriptionDraft.isSaved ? ( - Draft saved - ) : null}
- promptDraft.setValue(e.target.value)} + footerRight={ + promptDraft.isSaved ? ( + Draft saved + ) : null + } /> - {promptDraft.isSaved ? ( - Draft saved - ) : null}
@@ -175,11 +199,24 @@ export const CreateTaskDialog = ({ Unassigned {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 ( - {m.name} - {role ? ` (${role})` : ''} + + {memberColor ? ( + + ) : null} + + {m.name} + + {role ? ( + ({role}) + ) : null} + ); })} @@ -187,6 +224,19 @@ export const CreateTaskDialog = ({
+ {owner ? ( +
+ setStartImmediately(v === true)} + /> + +
+ ) : null} + {availableTasks.length > 0 ? (
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index d30acf6a..125d72ae 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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( + () => + 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( () => ({ teamName: teamName.trim(), @@ -744,18 +764,21 @@ export const CreateTeamDialog = ({ - promptDraft.setValue(event.target.value)} + onValueChange={promptDraft.setValue} + suggestions={mentionSuggestions} placeholder="Instructions for the team lead during provisioning..." + footerRight={ + promptDraft.isSaved ? ( + Draft saved + ) : null + } /> - {promptDraft.isSaved ? ( - Draft saved - ) : null}
) : null} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index c2b88de7..a50ae36c 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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( + () => + 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 = ({ Select member... {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 ( - {m.name} - {role ? ` (${role})` : ''} + + {memberColor ? ( + + ) : null} + + {m.name} + + {role ? ( + ({role}) + ) : null} + ); })} @@ -131,17 +157,20 @@ export const SendMessageDialog = ({
- textDraft.setValue(e.target.value)} + footerRight={ + textDraft.isSaved ? ( + Draft saved + ) : null + } /> - {textDraft.isSaved ? ( - Draft saved - ) : null}
{sendError ?

{sendError}

: null} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx new file mode 100644 index 00000000..185b187f --- /dev/null +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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; + 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 ( + !v && onClose()}> + + + Task not found + + + + ); + } + + 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 ( + !v && onClose()}> + + +
+ + #{currentTask.id} + + + {statusLabel} + +
+ {currentTask.subject} + {currentTask.activeForm ? ( + {currentTask.activeForm} + ) : null} +
+ + {/* Metadata */} +
+
+ + + {currentTask.owner ?? '\u2014'} + +
+ {currentTask.createdAt ? ( +
+ + + {formatDistanceToNow(new Date(currentTask.createdAt), { addSuffix: true })} + +
+ ) : null} +
+ + {/* Description */} +
+
+ + Description +
+ {currentTask.description ? ( +
+ +
+ ) : ( +

No description

+ )} +
+ + {/* Dependencies */} + {blockedByIds.length > 0 ? ( +
+ + + Blocked by + + {blockedByIds.map((id) => { + const depTask = taskMap.get(id); + const isCompleted = depTask?.status === 'completed'; + return ( + + ); + })} +
+ ) : null} + + {blocksIds.length > 0 ? ( +
+ + + Blocks + + {blocksIds.map((id) => { + const depTask = taskMap.get(id); + const isCompleted = depTask?.status === 'completed'; + return ( + + ); + })} +
+ ) : null} + + {/* Review info */} + {kanbanTaskState ? ( +
+ + {kanbanTaskState.reviewer ? ( + + Reviewer: {kanbanTaskState.reviewer} + + ) : null} + {kanbanTaskState.errorDescription ? ( + {kanbanTaskState.errorDescription} + ) : null} +
+ ) : null} + + {/* Separator */} +
+ + {/* Session Logs */} +
+

+ Execution Logs +

+ {currentTask.owner ? ( + + ) : ( +

+ Assign a member to see execution logs +

+ )} +
+ + + + + +
+ ); +}; diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 02630940..8b6c2673 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -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('grid'); @@ -110,8 +114,10 @@ export const KanbanBoard = ({ onApprove={onApprove} onRequestChanges={onRequestChanges} onMoveBackToDone={onMoveBackToDone} + onStartTask={onStartTask} onCompleteTask={onCompleteTask} onScrollToTask={onScrollToTask} + onTaskClick={onTaskClick} /> ))} diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index e40a53a6..683334f9 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -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 ( -
onTaskClick?.(task)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onTaskClick?.(task); + } + }} >
@@ -126,13 +139,47 @@ export const KanbanTaskCard = ({
) : null} - {columnId === 'todo' || columnId === 'in_progress' ? ( + {columnId === 'todo' ? ( +
+ + +
+ ) : null} + + {columnId === 'in_progress' ? ( @@ -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 @@ -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 @@ -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 ) : null} -
+ ); }; diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx new file mode 100644 index 00000000..df200374 --- /dev/null +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -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 {name}; + + const lower = name.toLowerCase(); + const qLower = query.toLowerCase(); + const idx = lower.indexOf(qLower); + + if (idx < 0) return {name}; + + const before = name.slice(0, idx); + const match = name.slice(idx, idx + query.length); + const after = name.slice(idx + query.length); + + return ( + + {before} + {match} + {after} + + ); +}; + +export const MentionSuggestionList = ({ + suggestions, + selectedIndex, + onSelect, + query, +}: MentionSuggestionListProps): React.JSX.Element => { + const listRef = useRef(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 ( +
+ No matching members +
+ ); + } + + return ( +
    + {suggestions.map((s, i) => { + const colorSet = s.color ? getTeamColorSet(s.color) : null; + const isSelected = i === selectedIndex; + + return ( +
  • { + e.preventDefault(); + onSelect(s); + }} + > + + + + + {s.subtitle ? ( + {s.subtitle} + ) : null} +
  • + ); + })} +
+ ); +}; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx new file mode 100644 index 00000000..efd0791c --- /dev/null +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -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( + ( + { + value, + onValueChange, + suggestions, + hintText = 'Use @ to mention team members', + showHint = true, + footerRight, + style, + ...textareaProps + }, + forwardedRef + ) => { + const internalRef = React.useRef(null); + const backdropRef = React.useRef(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 ( +
+ {/* Inner wrapper for textarea + backdrop overlay */} +
+ {hasMentionOverlay ? ( + + ) : null} + + +
+ + {showFooter ? ( +
+ {showHint && suggestions.length > 0 ? ( + {hintText} + ) : ( + + )} + {footerRight} +
+ ) : null} + {isOpen && dropdownPosition ? ( +
+ +
+ ) : null} +
+ ); + } +); +MentionableTextarea.displayName = 'MentionableTextarea'; diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index 9a000e62..f05cfef2 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -83,6 +83,7 @@ export const Combobox = ({ e.stopPropagation()} > {emptyMessage} diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts new file mode 100644 index 00000000..6731bc3c --- /dev/null +++ b/src/renderer/hooks/useMentionDetection.ts @@ -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; +} + +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) => void; + handleChange: (e: React.ChangeEvent) => void; + handleSelect: (e: React.SyntheticEvent) => 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(null); + const triggerIndexRef = useRef(-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) => { + 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) => { + const target = e.target as HTMLTextAreaElement; + detectTrigger(target.selectionStart); + }, + [detectTrigger] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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, + }; +} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 3f662bc3..215457cd 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -67,6 +67,7 @@ export interface TeamSlice { requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise; + startTask: (teamName: string, taskId: string) => Promise; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; deleteTeam: (teamName: string) => Promise; createTeam: (request: TeamCreateRequest) => Promise; @@ -361,6 +362,11 @@ export const createTeamSlice: StateCreator = (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) diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts new file mode 100644 index 00000000..bc1599cb --- /dev/null +++ b/src/renderer/types/mention.ts @@ -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; +} diff --git a/src/shared/constants/agentBlocks.ts b/src/shared/constants/agentBlocks.ts new file mode 100644 index 00000000..8301b151 --- /dev/null +++ b/src/shared/constants/agentBlocks.ts @@ -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' +); diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 2c33e377..e63f8343 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -2,6 +2,7 @@ * Shared constants barrel export. */ +export * from './agentBlocks'; export * from './cache'; export * from './memberColors'; export * from './trafficLights'; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 15621669..224be3fa 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -354,6 +354,7 @@ export interface TeamsAPI { requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; + startTask: (teamName: string, taskId: string) => Promise; processSend: (teamName: string, message: string) => Promise; processAlive: (teamName: string) => Promise; aliveList: () => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index bccade9c..dfcc0346 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -147,6 +147,7 @@ export interface CreateTaskRequest { owner?: string; blockedBy?: string[]; prompt?: string; + startImmediately?: boolean; } export interface TeamChangeEvent { diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index cf47732c..4d5b412d 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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); diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 3fbfcc93..2fad859d 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -96,4 +96,153 @@ describe('TeamMemberLogsFinder', () => { expect(lead?.sessionId).toBe(leadSessionId); expect(lead?.projectId).toBe(projectId); }); + + it('detects member via teammate_id attribute in 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 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: + 'Please implement the login page', + }, + }), + 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: + 'Do 200 things', + }, + }) + ); + // 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); + }); }); diff --git a/test/renderer/hooks/useMentionDetection.test.ts b/test/renderer/hooks/useMentionDetection.test.ts new file mode 100644 index 00000000..c20780bc --- /dev/null +++ b/test/renderer/hooks/useMentionDetection.test.ts @@ -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' }); + }); +});