From eacd7d82e187e81d63baed6cd6bebc83487ee684 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 17 Mar 2026 11:20:04 +0200 Subject: [PATCH 01/13] feat: implement project scanning timeout and enhance metadata extraction - Added scanProjectWithTimeout method to ProjectScanner to prevent long scans from blocking the entire batch. - Introduced cleanup logic in metadata extraction functions to ensure proper resource management during timeouts. - Updated logging to provide insights on scan durations and session statistics for better performance monitoring. - Enhanced repository group fetching logic in DashboardView and DateGroupedSessions components to handle loading and error states more effectively. --- src/main/services/discovery/ProjectScanner.ts | 44 +++++++++- src/main/utils/metadataExtraction.ts | 81 ++++++++++++------- .../components/dashboard/DashboardView.tsx | 9 ++- .../sidebar/DateGroupedSessions.tsx | 13 ++- 4 files changed, 112 insertions(+), 35 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index c9056f04..6bcd9cab 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -168,7 +168,7 @@ export class ProjectScanner { const projectArrays = await this.collectFulfilledInBatches( projectDirs, this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH, - async (dir) => this.scanProject(dir.name) + async (dir) => this.scanProjectWithTimeout(dir.name) ); // Flatten and sort by most recent @@ -312,6 +312,31 @@ export class ProjectScanner { // Project Scanning (continued) // =========================================================================== + // Per-project scan timeout: prevents a single slow directory from blocking + // the entire scan batch (e.g. a project with 1000+ session files on slow I/O). + private static readonly SCAN_PROJECT_TIMEOUT_MS = 15_000; + + /** + * Scans a single project directory with a timeout guard. + * Returns empty array if the scan exceeds the timeout. + */ + private async scanProjectWithTimeout(encodedName: string): Promise { + let timer: ReturnType | null = null; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + logger.warn( + `[scanProject] timeout after ${ProjectScanner.SCAN_PROJECT_TIMEOUT_MS}ms project=${encodedName}` + ); + resolve([]); + }, ProjectScanner.SCAN_PROJECT_TIMEOUT_MS); + }); + try { + return await Promise.race([this.scanProject(encodedName), timeout]); + } finally { + if (timer) clearTimeout(timer); + } + } + /** * Scans a single project directory and returns project metadata. * If sessions have different cwd values, splits into multiple projects. @@ -319,7 +344,9 @@ export class ProjectScanner { private async scanProject(encodedName: string): Promise { try { const projectPath = path.join(this.projectsDir, encodedName); + const readdirStart = Date.now(); const entries = await this.fsProvider.readdir(projectPath); + const readdirMs = Date.now() - readdirStart; // Get session files (.jsonl at root level) const sessionFiles = entries.filter( @@ -330,6 +357,12 @@ export class ProjectScanner { return []; } + if (sessionFiles.length > 200 || readdirMs > 500) { + logger.debug( + `[scanProject] ${encodedName} readdir=${readdirMs}ms entries=${entries.length} jsonl=${sessionFiles.length}` + ); + } + // Collect file stats and cwd for each session interface SessionInfo { sessionId: string; @@ -346,6 +379,8 @@ export class ProjectScanner { const MAX_CWD_SPLIT_FILES = 80; const shouldSplitByCwd = this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES; + + const sessionStatStart = Date.now(); const sessionInfos = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH, @@ -377,6 +412,13 @@ export class ProjectScanner { return []; } + const sessionStatMs = Date.now() - sessionStatStart; + if (sessionFiles.length > 200 || sessionStatMs > 1000) { + logger.debug( + `[scanProject] ${encodedName} sessionStat=${sessionStatMs}ms files=${sessionFiles.length} infos=${sessionInfos.length}` + ); + } + // Group sessions by cwd const cwdGroups = new Map(); const firstCwd = sessionInfos.find((s) => s.cwd)?.cwd ?? undefined; diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index 5b717093..8ec70183 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -51,23 +51,37 @@ export async function extractCwd( } const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); - let bytes = 0; - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - fileStream.destroy(); - }, JSONL_HEAD_TIMEOUT_MS); - fileStream.on('data', (chunk: string) => { - bytes += byteLen(chunk); - if (bytes > JSONL_HEAD_MAX_BYTES) { - fileStream.destroy(); - } - }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); + let bytes = 0; + let timedOut = false; + let cleaned = false; + + // Close readline FIRST so `for await` exits, then destroy the stream. + // Calling only stream.destroy() can leave readline hanging when it has + // a partial line buffered (e.g. a 400KB+ JSONL line read in 64KB chunks). + const cleanup = (): void => { + if (cleaned) return; + cleaned = true; + rl.close(); + fileStream.destroy(); + }; + + const timer = setTimeout(() => { + timedOut = true; + cleanup(); + }, JSONL_HEAD_TIMEOUT_MS); + + fileStream.on('data', (chunk: string) => { + bytes += byteLen(chunk); + if (bytes > JSONL_HEAD_MAX_BYTES) { + cleanup(); + } + }); + try { let lines = 0; for await (const line of rl) { @@ -84,8 +98,6 @@ export async function extractCwd( } // Only conversational entries have cwd if ('cwd' in entry && entry.cwd) { - rl.close(); - fileStream.destroy(); return entry.cwd; } } @@ -95,8 +107,7 @@ export async function extractCwd( } } finally { clearTimeout(timer); - rl.close(); - fileStream.destroy(); + cleanup(); } return null; @@ -122,23 +133,34 @@ export async function extractFirstUserMessagePreview( } const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); - let bytes = 0; - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - fileStream.destroy(); - }, JSONL_HEAD_TIMEOUT_MS); - fileStream.on('data', (chunk: string) => { - bytes += byteLen(chunk); - if (bytes > JSONL_HEAD_MAX_BYTES) { - fileStream.destroy(); - } - }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); + let bytes = 0; + let timedOut = false; + let cleaned = false; + + const cleanup = (): void => { + if (cleaned) return; + cleaned = true; + rl.close(); + fileStream.destroy(); + }; + + const timer = setTimeout(() => { + timedOut = true; + cleanup(); + }, JSONL_HEAD_TIMEOUT_MS); + + fileStream.on('data', (chunk: string) => { + bytes += byteLen(chunk); + if (bytes > JSONL_HEAD_MAX_BYTES) { + cleanup(); + } + }); + let commandFallback: { text: string; timestamp: string } | null = null; let linesRead = 0; @@ -182,8 +204,7 @@ export async function extractFirstUserMessagePreview( return commandFallback; } finally { clearTimeout(timer); - rl.close(); - fileStream.destroy(); + cleanup(); } return commandFallback; diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 6afa98fc..2ac49c67 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -518,10 +518,15 @@ const ProjectsGrid = ({ const [visibleProjects, setVisibleProjects] = useState(maxProjects); useEffect(() => { - if (repositoryGroups.length === 0 && !repositoryGroupsLoading) { + if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) { void fetchRepositoryGroups(); } - }, [repositoryGroups.length, repositoryGroupsLoading, fetchRepositoryGroups]); + }, [ + repositoryGroups.length, + repositoryGroupsLoading, + repositoryGroupsError, + fetchRepositoryGroups, + ]); useEffect(() => { if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) { diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 620108fc..dd98b1a3 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -241,11 +241,18 @@ export const DateGroupedSessions = (): React.JSX.Element => { // Loading guards in the store actions prevent duplicate IPC calls // when the centralized init chain has already started a fetch. const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading); + const repositoryGroupsError = useStore((s) => s.repositoryGroupsError); const projectsLoading = useStore((s) => s.projectsLoading); + const projectsError = useStore((s) => s.projectsError); useEffect(() => { - if (viewMode === 'grouped' && repositoryGroups.length === 0 && !repositoryGroupsLoading) { + if ( + viewMode === 'grouped' && + repositoryGroups.length === 0 && + !repositoryGroupsLoading && + !repositoryGroupsError + ) { void fetchRepositoryGroups(); - } else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading) { + } else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) { void fetchProjects(); } }, [ @@ -253,7 +260,9 @@ export const DateGroupedSessions = (): React.JSX.Element => { repositoryGroups.length, projects.length, repositoryGroupsLoading, + repositoryGroupsError, projectsLoading, + projectsError, fetchRepositoryGroups, fetchProjects, ]); From 572cfab1a589c4931af37571638eae030e21bd23 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 17 Mar 2026 13:53:29 +0200 Subject: [PATCH 02/13] feat: enhance attachment handling and metadata management - Added filePath to attachment metadata in various components to support file access. - Updated saveTaskAttachmentFile and related functions to include file paths for stored attachments. - Enhanced documentation and comments to clarify the importance of using MCP tools for task review operations. - Improved UI components to display file paths where applicable, ensuring better user experience with attachments. --- .../src/internal/runtimeHelpers.js | 1 + agent-teams-controller/src/internal/tasks.js | 1 + mcp-server/src/tools/taskTools.ts | 15 +- src/main/ipc/teams.ts | 37 ++- src/main/services/team/TeamAttachmentStore.ts | 210 ++++++++++++++++-- .../services/team/TeamProvisioningService.ts | 8 + .../services/team/TeamTaskAttachmentStore.ts | 1 + .../attachments/SourceMessageAttachments.tsx | 1 + .../team/messages/MessagesPanel.tsx | 7 +- src/renderer/constants/teamColors.ts | 10 +- src/shared/types/team.ts | 14 +- 11 files changed, 261 insertions(+), 44 deletions(-) diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index 78d19b90..dae273fb 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -440,6 +440,7 @@ function saveTaskAttachmentFile(paths, taskId, flags) { mimeType, size: stats.size, addedAt: nowIso(), + filePath: destPath, }; return { diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 1a211c0a..d4eff2ac 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -393,6 +393,7 @@ function buildMemberTaskProtocol(teamName) { This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review. 4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } + CRITICAL: Text comments like "approved" or "LGTM" do NOT change the kanban board. You MUST call review_approve to move a task from REVIEW to APPROVED. Without the tool call the task stays stuck in the REVIEW column. 5. If review fails and changes are needed, use MCP tool review_request_changes: { teamName: "${teamName}", taskId: "", comment: "" } 6. NEVER skip status updates. A task is NOT done until completed status is written. diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 3c2ee87a..bbac5d51 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -170,7 +170,7 @@ export function registerTaskTools(server: Pick) { ...(source ? { source } : {}), }; - // Preserve attachment metadata by reference only — no blob copying + // Preserve attachment metadata — filePath included when available if (Array.isArray(message.attachments) && message.attachments.length > 0) { sourceMessage.attachments = (message.attachments as Record[]) .filter( @@ -185,6 +185,7 @@ export function registerTaskTools(server: Pick) { filename: String(a.filename), mimeType: typeof a.mimeType === 'string' ? a.mimeType : '', size: typeof a.size === 'number' ? a.size : 0, + ...(typeof a.filePath === 'string' ? { filePath: a.filePath } : {}), })); } @@ -212,7 +213,11 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_get', - description: 'Get a task by id', + description: + 'Get a task by id. Response includes:\n' + + '- sourceMessage.attachments: from original user message (filePath = absolute path to file on disk, use Read tool to view)\n' + + '- attachments: files attached to the task (filePath = absolute path, use Read tool to view)\n' + + '- comments[].attachments: files on comments (filePath = absolute path, use Read tool to view)', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), @@ -314,7 +319,8 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_attach_file', - description: 'Attach a file to a task', + description: + 'Attach a file to a task. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), @@ -351,7 +357,8 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_attach_comment_file', - description: 'Attach a file to a task comment', + description: + 'Attach a file to a task comment. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a2693474..e0847b54 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1247,12 +1247,30 @@ async function handleSendMessage( } if (stdinSent) { - const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ - id: a.id, - filename: a.filename, - mimeType: a.mimeType, - size: a.size, - })); + // Save attachment files to disk FIRST to get file paths for metadata + let attachmentFilePaths: Map | undefined; + if (validatedAttachments?.length) { + try { + attachmentFilePaths = await attachmentStore.saveAttachments( + tn, + preGeneratedMessageId, + validatedAttachments + ); + } catch (e) { + logger.warn(`Failed to save attachments: ${e}`); + } + } + + const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => { + const fp = attachmentFilePaths?.get(a.id); + return { + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + ...(fp ? { filePath: fp } : {}), + }; + }); // Persistence is best-effort — stdin already delivered the message let result: SendMessageResult; @@ -1271,12 +1289,7 @@ async function handleSendMessage( result = { deliveredToInbox: false, messageId: preGeneratedMessageId }; } - // Save attachment binary data to disk (best-effort) - if (validatedAttachments?.length && result.messageId) { - void attachmentStore - .saveAttachments(tn, result.messageId, validatedAttachments) - .catch((e) => logger.warn(`Failed to save attachments: ${e}`)); - } + // Attachment files already saved above (before metadata construction) provisioning.pushLiveLeadProcessMessage(tn, { from: 'user', diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index 998139f4..675080e5 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -3,18 +3,27 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import { atomicWriteAsync } from './atomicWrite'; - import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); -const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap +const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB per file +const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB legacy JSON cap + +/** Per-attachment metadata stored in the index file. */ +interface StoredAttachmentIndex { + id: string; + filename: string; + mimeType: string; +} export class TeamAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( value.length === 0 || + value.trim().length === 0 || + value === '.' || + value === '..' || value.includes('/') || value.includes('\\') || value.includes('..') || @@ -24,37 +33,204 @@ export class TeamAttachmentStore { } } - private getDir(teamName: string): string { + private sanitizeStoredFilename(original: string): string { + const raw = String(original ?? '').trim(); + const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : ''; + const cleaned = base + .replace(/\0/g, '') + .replace(/[\r\n\t]/g, ' ') + .replace(/[\\/]/g, '_') + .trim(); + if (!cleaned) return 'attachment'; + return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned; + } + + /** Base directory for all message attachments of a team. */ + private getTeamDir(teamName: string): string { this.assertSafePathSegment('teamName', teamName); return path.join(getAppDataPath(), 'attachments', teamName); } - private getFilePath(teamName: string, messageId: string): string { + /** Directory for a specific message's attachments (new file-based format). */ + private getMessageDir(teamName: string, messageId: string): string { this.assertSafePathSegment('messageId', messageId); - return path.join(this.getDir(teamName), `${messageId}.json`); + return path.join(this.getTeamDir(teamName), messageId); } + /** Path to the metadata index file inside a message attachment directory. */ + private getIndexPath(teamName: string, messageId: string): string { + return path.join(this.getMessageDir(teamName, messageId), '_index.json'); + } + + /** Legacy JSON bundle path (old format). */ + private getLegacyFilePath(teamName: string, messageId: string): string { + this.assertSafePathSegment('messageId', messageId); + return path.join(this.getTeamDir(teamName), `${messageId}.json`); + } + + /** Stored file path for an individual attachment. */ + private getStoredFilePath( + teamName: string, + messageId: string, + attachmentId: string, + filename: string + ): string { + this.assertSafePathSegment('attachmentId', attachmentId); + const safeName = this.sanitizeStoredFilename(filename); + return path.join(this.getMessageDir(teamName, messageId), `${attachmentId}--${safeName}`); + } + + /** + * Save message attachments as individual files on disk. + * Returns a Map of attachmentId → absolute file path for each saved file. + */ async saveAttachments( teamName: string, messageId: string, attachments: AttachmentPayload[] - ): Promise { - if (attachments.length === 0) return; + ): Promise> { + const filePaths = new Map(); + if (attachments.length === 0) return filePaths; - const fileData: AttachmentFileData[] = attachments.map((a) => ({ - id: a.id, - data: a.data, - mimeType: a.mimeType, - })); + const dir = this.getMessageDir(teamName, messageId); + await fs.promises.mkdir(dir, { recursive: true }); + + const indexEntries: StoredAttachmentIndex[] = []; + + for (const att of attachments) { + const buffer = Buffer.from(att.data, 'base64'); + if (buffer.length > MAX_ATTACHMENT_SIZE) { + logger.warn( + `[${teamName}] Skipping oversized attachment ${att.id} (${(buffer.length / (1024 * 1024)).toFixed(1)} MB)` + ); + continue; + } + + const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename); + try { + await fs.promises.writeFile(storedPath, buffer); + } catch (writeError) { + logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`); + continue; + } + filePaths.set(att.id, storedPath); + + indexEntries.push({ + id: att.id, + filename: att.filename, + mimeType: att.mimeType, + }); + } + + // Write metadata index for successful files (mimeType, original filename) + if (indexEntries.length > 0) { + const indexPath = this.getIndexPath(teamName, messageId); + await fs.promises.writeFile(indexPath, JSON.stringify(indexEntries, null, 2)); + } - await atomicWriteAsync(this.getFilePath(teamName, messageId), JSON.stringify(fileData)); logger.debug( - `[${teamName}] Saved ${attachments.length} attachment(s) for message ${messageId}` + `[${teamName}] Saved ${filePaths.size} attachment file(s) for message ${messageId}` ); + return filePaths; } + /** + * Returns a Map of attachmentId → absolute file path. + * Only available for new file-based format. Legacy JSON bundles have no individual file paths. + */ + async getAttachmentFilePaths(teamName: string, messageId: string): Promise> { + const result = new Map(); + + // Try new file-based format first + const dir = this.getMessageDir(teamName, messageId); + try { + const entries = await fs.promises.readdir(dir); + for (const entry of entries) { + if (entry === '_index.json') continue; + const dashIdx = entry.indexOf('--'); + if (dashIdx > 0) { + const attachmentId = entry.slice(0, dashIdx); + result.set(attachmentId, path.join(dir, entry)); + } + } + if (result.size > 0) return result; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + // No new-format files found — not available for legacy format + return result; + } + + /** + * Read attachment data (base64) for rendering in UI. + * Supports both new file-based format and legacy JSON bundle. + */ async getAttachments(teamName: string, messageId: string): Promise { - const filePath = this.getFilePath(teamName, messageId); + // Try new file-based format first + const newResult = await this.readNewFormatAttachments(teamName, messageId); + if (newResult !== null) return newResult; + + // Fallback to legacy JSON format + return this.readLegacyAttachments(teamName, messageId); + } + + /** Read attachments from new file-based directory format. */ + private async readNewFormatAttachments( + teamName: string, + messageId: string + ): Promise { + const indexPath = this.getIndexPath(teamName, messageId); + + let indexRaw: string; + try { + indexRaw = await fs.promises.readFile(indexPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + + let index: StoredAttachmentIndex[]; + try { + const parsed = JSON.parse(indexRaw) as unknown; + if (!Array.isArray(parsed)) return null; + index = parsed as StoredAttachmentIndex[]; + } catch { + return null; + } + + const result: AttachmentFileData[] = []; + for (const entry of index) { + if (!entry || typeof entry.id !== 'string') continue; + const filename = + typeof entry.filename === 'string' && entry.filename ? entry.filename : 'attachment'; + const mimeType = + typeof entry.mimeType === 'string' && entry.mimeType + ? entry.mimeType + : 'application/octet-stream'; + const filePath = this.getStoredFilePath(teamName, messageId, entry.id, filename); + try { + const buffer = await fs.promises.readFile(filePath); + result.push({ + id: entry.id, + data: buffer.toString('base64'), + mimeType, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + } + + return result; + } + + /** Read attachments from legacy JSON bundle format. */ + private async readLegacyAttachments( + teamName: string, + messageId: string + ): Promise { + const filePath = this.getLegacyFilePath(teamName, messageId); let raw: string; try { @@ -103,5 +279,5 @@ export class TeamAttachmentStore { } // TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends. - // Best-effort removal of the attachment JSON file — useful for retry/cancel flows. + // Best-effort removal of attachment files — useful for retry/cancel flows. } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7330f878..5acfae5c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -558,6 +558,13 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, ``, + `Review operations — use MCP tools directly (text comments do NOT change kanban state):`, + `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, + `- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "", from: "" }`, + `- Approve review: review_approve { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true }`, + `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", comment: "" }`, + `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, + ``, `Attachment storage modes (IMPORTANT):`, `- Default is copy (safe, robust).`, `- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`, @@ -831,6 +838,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { - If a task is blocked (uses blockedBy), it MUST be created as pending (for example with task_create + startImmediately: false). Do NOT mark blocked tasks in_progress. - Review guidance: - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: call review_start when beginning review, then review_approve/review_request_changes on the implementation task #X. + - CRITICAL: Text comments ("approved", "LGTM") do NOT move the task on the kanban board. You MUST call the MCP tool review_approve to move from REVIEW to APPROVED. Without the tool call, the task stays stuck in REVIEW. - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: - Use related to connect it to #X (non-blocking link). - If the review truly cannot start until #X is done, ALSO add blockedBy #X. diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 45d95a4f..7e509f2a 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -124,6 +124,7 @@ export class TeamTaskAttachmentStore { mimeType, size: buffer.length, addedAt: new Date().toISOString(), + filePath, }; logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`); diff --git a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx index 46f6f72d..7e162eef 100644 --- a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx +++ b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx @@ -23,6 +23,7 @@ export const SourceMessageAttachments = ({ filename: a.filename, mimeType: a.mimeType, size: a.size, + ...(a.filePath ? { filePath: a.filePath } : {}), })); const truncatedText = diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 9ae749bb..1bc7c59d 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -9,7 +9,6 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { Bell, CheckCheck, @@ -321,7 +320,7 @@ export const MessagesPanel = memo(function MessagesPanel({ ); const messagesContent = ( - <> +
- +
); // ---- Sidebar mode ---- @@ -497,7 +496,7 @@ export const MessagesPanel = memo(function MessagesPanel({ )} {/* Scrollable content */} -
+
= { }, /** Reserved for the human user — never assigned to team members. */ user: { - border: '#f5f5f4', - borderLight: '#78716c', - badge: 'rgba(245, 245, 244, 0.12)', - badgeLight: 'rgba(87, 83, 78, 0.18)', + border: '#a8a29e', + borderLight: '#57534e', + badge: 'rgba(168, 162, 158, 0.18)', + badgeLight: 'rgba(68, 64, 60, 0.14)', text: '#d6d3d1', - textLight: '#44403c', + textLight: '#292524', }, }; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c13d9d24..1d016592 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -154,8 +154,14 @@ export interface SourceMessageSnapshot { timestamp: string; /** Message source type (e.g. "user_sent", "inbox"). */ source?: string; - /** Attachment metadata references (IDs only, no blobs). */ - attachments?: { id: string; filename: string; mimeType: string; size: number }[]; + /** Attachment metadata references (IDs only, no blobs). filePath present when file is stored on disk. */ + attachments?: { + id: string; + filename: string; + mimeType: string; + size: number; + filePath?: string; + }[]; } // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. @@ -229,6 +235,8 @@ export interface TaskAttachmentMeta { size: number; /** ISO timestamp when the attachment was added. */ addedAt: string; + /** Absolute path to the file on disk. Null/absent for metadata-only references. */ + filePath?: string | null; } /** Payload for uploading an attachment with base64 data (renderer → main). */ @@ -256,6 +264,8 @@ export interface AttachmentMeta { filename: string; mimeType: AttachmentMediaType; size: number; + /** Absolute path to the file on disk. Absent for metadata-only references. */ + filePath?: string; } export interface AttachmentPayload extends AttachmentMeta { From 72ae20bda73558d960eb0fb5c87372635c806110 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 17 Mar 2026 14:10:08 +0200 Subject: [PATCH 03/13] feat: update review instructions and enhance team operations documentation - Modified review request instructions to include an optional note parameter for better context during reviews. - Added clarification flag instruction to team operations documentation, improving guidance for task management. --- agent-teams-controller/src/internal/review.js | 2 +- src/main/services/team/TeamProvisioningService.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index bf4dcade..8e5d4ed2 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -125,7 +125,7 @@ function requestReview(context, taskId, flags = {}) { `FIRST call review_start to signal you are beginning the review:\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "" }\n\n` + `When approved, use MCP tool review_approve:\n` + - `{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` + + `{ teamName: "${context.teamName}", taskId: "${task.id}", note?: "", notifyOwner: true }\n\n` + `If changes are needed, use MCP tool review_request_changes:\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }` ), diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5acfae5c..5ddc2160 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -557,6 +557,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Link dependency: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, + `- Set clarification flag: task_set_clarification { teamName: "${teamName}", taskId: "", value: "lead"|"user"|"clear" }`, ``, `Review operations — use MCP tools directly (text comments do NOT change kanban state):`, `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, From 5ae1d4164a23f306039daec5a0c993e1deda017f Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 17 Mar 2026 14:14:14 +0200 Subject: [PATCH 04/13] feat: enhance team operations documentation with additional task commands - Added instructions for retrieving task details and listing all tasks to the team operations documentation. - Improved clarity on task management processes within the team provisioning service. --- src/main/services/team/TeamProvisioningService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5ddc2160..d95e4c56 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -541,6 +541,8 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `IMPORTANT: The board MCP only supports these domains: task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, ``, `Task board operations — use MCP tools directly:`, + `- Get task details: task_get { teamName: "${teamName}", taskId: "" }`, + `- List all tasks: task_list { teamName: "${teamName}" }`, `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "", subject: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, From 6ace70765332df135173d4cd4ee62eecf7d5384c Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 17 Mar 2026 15:13:37 +0200 Subject: [PATCH 05/13] feat: add team launched notification and related configuration - Introduced a new notification setting for when a team finishes launching, enhancing user awareness of team readiness. - Updated the configuration interface and default settings to include the new notification option. - Implemented logic to trigger the "team launched" notification within the team provisioning service. - Enhanced validation to ensure the new setting accepts boolean values. - Updated relevant UI components to allow users to toggle the new notification setting. --- src/main/ipc/configValidation.ts | 7 +++ .../services/error/ErrorMessageBuilder.ts | 14 +----- .../services/infrastructure/ConfigManager.ts | 3 ++ .../infrastructure/NotificationManager.ts | 3 +- .../services/team/TeamProvisioningService.ts | 44 +++++++++++++++++++ src/main/utils/teamNotificationBuilder.ts | 22 ++++------ .../settings/hooks/useSettingsConfig.ts | 5 ++- .../settings/hooks/useSettingsHandlers.ts | 1 + .../sections/NotificationsSection.tsx | 13 ++++++ .../team/CollapsibleTeamSection.tsx | 16 ++++++- .../team/dialogs/CreateTeamDialog.tsx | 33 +++++--------- .../team/dialogs/TaskDetailDialog.tsx | 1 + .../team/dialogs/TeamModelSelector.tsx | 24 +++++++++- src/renderer/store/slices/teamSlice.ts | 12 +++-- src/shared/types/notifications.ts | 33 +++++++++----- test/main/ipc/configValidation.test.ts | 2 + .../utils/teamNotificationBuilder.test.ts | 1 + 17 files changed, 165 insertions(+), 69 deletions(-) diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 4cfc74a9..084e816c 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -117,6 +117,7 @@ function validateNotificationsSection( 'notifyOnTaskCreated', 'notifyOnAllTasksCompleted', 'notifyOnCrossTeamMessage', + 'notifyOnTeamLaunched', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -199,6 +200,12 @@ function validateNotificationsSection( } result.notifyOnCrossTeamMessage = value; break; + case 'notifyOnTeamLaunched': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnTeamLaunched = value; + break; case 'statusChangeOnlySolo': if (typeof value !== 'boolean') { return { valid: false, error: `notifications.${key} must be a boolean` }; diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 43141da9..1fdfe40b 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -14,6 +14,7 @@ import { randomUUID } from 'crypto'; import { type ExtractedToolResult } from '../analysis/ToolResultExtractor'; import type { TriggerColor } from '@shared/constants/triggerColors'; +import type { TeamEventType } from '@shared/types/notifications'; // ============================================================================= // Types @@ -52,18 +53,7 @@ export interface DetectedError { /** Notification domain: 'error' (default/undefined) or 'team' */ category?: 'error' | 'team'; /** For team notifications: specific event sub-type */ - teamEventType?: - | 'rate_limit' - | 'lead_inbox' - | 'user_inbox' - | 'task_clarification' - | 'task_status_change' - | 'task_comment' - | 'task_created' - | 'all_tasks_completed' - | 'cross_team_message' - | 'schedule_completed' - | 'schedule_failed'; + teamEventType?: TeamEventType; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ dedupeKey?: string; /** Additional context about the error */ diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 2733948a..79999f95 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -56,6 +56,8 @@ export interface NotificationConfig { notifyOnAllTasksCompleted: boolean; /** Whether to show native OS notifications for cross-team messages */ notifyOnCrossTeamMessage: boolean; + /** Whether to show native OS notifications when a team finishes launching */ + notifyOnTeamLaunched: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -273,6 +275,7 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnTaskCreated: true, notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, + notifyOnTeamLaunched: true, statusChangeOnlySolo: false, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 58a5d2ed..c76543bd 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -18,6 +18,7 @@ import { getAppIconPath } from '@main/utils/appIcon'; import { getHomeDir } from '@main/utils/pathDecoder'; import { stripMarkdown } from '@main/utils/textFormatting'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; import { type BrowserWindow, Notification } from 'electron'; import { EventEmitter } from 'events'; @@ -429,7 +430,7 @@ export class NotificationManager extends EventEmitter { try { const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; - const truncatedBody = stripMarkdown(payload.body).slice(0, 300); + const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300); const iconPath = isMac ? undefined : getAppIconPath(); logger.debug( diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d95e4c56..ed847516 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; +import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { @@ -5393,6 +5394,9 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); + // Fire "Team Launched" notification + void this.fireTeamLaunchedNotification(run); + // Pick up any direct messages that arrived before/while reconnecting. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`) @@ -5486,12 +5490,52 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); + // Fire "Team Launched" notification + void this.fireTeamLaunchedNotification(run); + // Pick up any direct messages that arrived during provisioning. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`) ); } + // --------------------------------------------------------------------------- + // Team Launched notification + // --------------------------------------------------------------------------- + + /** + * Fires a "team_launched" notification when a team transitions to ready state. + * Uses the existing addTeamNotification() pipeline. + */ + private async fireTeamLaunchedNotification(run: ProvisioningRun): Promise { + try { + const config = ConfigManager.getInstance().getConfig(); + const suppressToast = !config.notifications.notifyOnTeamLaunched; + const displayName = run.request.displayName || run.teamName; + const body = run.isLaunch + ? `Team "${displayName}" has been launched and is ready for tasks.` + : `Team "${displayName}" has been provisioned and is ready for tasks.`; + + await NotificationManager.getInstance().addTeamNotification({ + teamEventType: 'team_launched', + teamName: run.teamName, + teamDisplayName: displayName, + from: 'system', + summary: run.isLaunch ? 'Team launched' : 'Team provisioned', + body, + dedupeKey: `team_launched:${run.teamName}:${run.runId}`, + projectPath: run.request.cwd, + suppressToast, + }); + } catch (error) { + logger.warn( + `[${run.teamName}] Failed to fire team_launched notification: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + // --------------------------------------------------------------------------- // Same-team native delivery dedup (Layer 2) // --------------------------------------------------------------------------- diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index 63a01d75..f038a588 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -7,26 +7,19 @@ import { randomUUID } from 'crypto'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; + import type { DetectedError } from '../services/error/ErrorMessageBuilder'; import type { TriggerColor } from '@shared/constants/triggerColors'; +import type { TeamEventType } from '@shared/types/notifications'; + +// Re-export for callers that import TeamEventType from this module +export type { TeamEventType } from '@shared/types/notifications'; // ============================================================================= // Types // ============================================================================= -export type TeamEventType = - | 'rate_limit' - | 'lead_inbox' - | 'user_inbox' - | 'task_clarification' - | 'task_status_change' - | 'task_comment' - | 'task_created' - | 'all_tasks_completed' - | 'cross_team_message' - | 'schedule_completed' - | 'schedule_failed'; - /** * Domain payload for team notifications. * Single source of truth — both storage and native presentation are derived from this. @@ -71,6 +64,7 @@ const TEAM_NOTIFICATION_CONFIG: Record = cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, + team_launched: { triggerName: 'Team Launched', triggerColor: 'green' }, }; // ============================================================================= @@ -91,7 +85,7 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De projectId: payload.teamName, filePath: '', source: payload.teamEventType, - message: `[${payload.from}] ${payload.body.slice(0, 300)}`, + message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`, category: 'team', teamEventType: payload.teamEventType, dedupeKey: payload.dedupeKey, diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index eb5ff447..47bd4bdd 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -50,6 +50,7 @@ export interface SafeConfig { notifyOnTaskCreated: boolean; notifyOnAllTasksCompleted: boolean; notifyOnCrossTeamMessage: boolean; + notifyOnTeamLaunched: boolean; statusChangeOnlySolo: boolean; statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; @@ -185,9 +186,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true, notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true, notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true, - notifyOnAllTasksCompleted: - displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true, + notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true, notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true, + notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true, statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ 'in_progress', diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 1ed4f7c4..8e797cb2 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -303,6 +303,7 @@ export function useSettingsHandlers({ notifyOnTaskCreated: true, notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, + notifyOnTeamLaunched: true, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index b2cede91..94341322 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -26,6 +26,7 @@ import { Mail, MessageSquare, PartyPopper, + Rocket, Send, Users, Volume2, @@ -72,6 +73,7 @@ interface NotificationsSectionProps { | 'notifyOnTaskCreated' | 'notifyOnAllTasksCompleted' | 'notifyOnCrossTeamMessage' + | 'notifyOnTeamLaunched' | 'statusChangeOnlySolo', value: boolean ) => void; @@ -334,6 +336,17 @@ export const NotificationsSection = ({ disabled={saving || !safeConfig.notifications.enabled} /> + } + > + onNotificationToggle('notifyOnTeamLaunched', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + {/* Task Status Change Notifications — nested within team card */}
diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 2cd656ee..485567e1 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -35,6 +35,8 @@ interface CollapsibleTeamSectionProps { headerClassName?: string; /** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */ headerContentClassName?: string; + /** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */ + keepMounted?: boolean; children: React.ReactNode; } @@ -53,6 +55,7 @@ export const CollapsibleTeamSection = ({ contentClassName, headerClassName, headerContentClassName, + keepMounted, children, }: CollapsibleTeamSectionProps): React.JSX.Element => { const [open, setOpen] = useState(defaultOpen); @@ -133,10 +136,19 @@ export const CollapsibleTeamSection = ({
{action}
)}
- {isOpen && ( -
+ {keepMounted ? ( +
{children}
+ ) : ( + isOpen && ( +
+ {children} +
+ ) )} ); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index e215137f..36dfd18b 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -103,11 +103,8 @@ interface ValidationResult { } import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; -const DEV_DEFAULT_TEAM = { - teamName: 'signal-ops', -} as const; -const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [ +const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [ { name: 'alice', roleSelection: 'reviewer', @@ -228,7 +225,6 @@ export const CreateTeamDialog = ({ onCreate, onOpenTeam, }: CreateTeamDialogProps): React.JSX.Element => { - const isDev = process.env.NODE_ENV !== 'production'; const { isLight } = useTheme(); const [teamName, setTeamName] = useState(''); @@ -503,20 +499,15 @@ export const CreateTeamDialog = ({ return; } - if (isDev) { - setMembers( - DEV_DEFAULT_MEMBERS.map((member) => - createMemberDraft({ - name: member.name, - roleSelection: member.roleSelection, - workflow: member.workflow, - }) - ) - ); - return; - } - - setMembers([createMemberDraft()]); + setMembers( + DEFAULT_MEMBERS.map((member) => + createMemberDraft({ + name: member.name, + roleSelection: member.roleSelection, + workflow: member.workflow, + }) + ) + ); // eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open }, [open]); @@ -528,7 +519,7 @@ export const CreateTeamDialog = ({ }, [initialData, open, suggestedTeamName]); useEffect(() => { - if (!open || !isDev || initialData) { + if (!open || initialData) { return; } const resolvedTeamName = teamName.trim() || suggestedTeamName; @@ -547,7 +538,7 @@ export const CreateTeamDialog = ({ if (currentDescription === nextAutoDescription) { lastAutoDescriptionRef.current = nextAutoDescription; } - }, [descriptionDraft, initialData, isDev, open, suggestedTeamName, teamName]); + }, [descriptionDraft, initialData, open, suggestedTeamName, teamName]); // Pre-select defaultProjectPath when projects loaded (only while dialog is open) useEffect(() => { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index abeff191..0730d67a 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -1107,6 +1107,7 @@ export const TaskDetailDialog = ({ headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} + keepMounted >
= ({ type="button" id={opt.value === value ? id : undefined} className={cn( - 'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', + 'flex items-center gap-1 rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', value === opt.value ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]' @@ -172,6 +178,20 @@ export const TeamModelSelector: React.FC = ({ onClick={() => onValueChange(opt.value)} > {opt.label} + {opt.value === '' && ( + + + e.stopPropagation()}> + + + + Default model from Claude CLI (/model). +
+ Currently Sonnet 4.6, but may change with CLI updates. +
+
+
+ )} ))}
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 91ef7aaf..af1995ea 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -7,6 +7,7 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -145,8 +146,9 @@ function detectClarificationNotifications( function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void { // Delegate to main process for native OS notification (cross-platform, no permission needed) const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined; - const body = + const rawBody = latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`; + const body = stripAgentBlocks(rawBody).trim(); void api.teams ?.showMessageNotification({ @@ -295,7 +297,11 @@ function fireTaskCommentNotification( comment: { author: string; text: string; id: string }, suppressToast: boolean ): void { - const preview = comment.text.length > 100 ? comment.text.slice(0, 100) + '...' : comment.text; + // Double-check: never notify about user's own comments + if (comment.author === 'user') return; + + const stripped = stripAgentBlocks(comment.text).trim(); + const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped; void api.teams ?.showMessageNotification({ @@ -337,7 +343,7 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean): from: task.owner ?? 'system', to: 'user', summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`, - body: task.description || task.subject, + body: stripAgentBlocks(task.description || task.subject).trim(), teamEventType: 'task_created', dedupeKey: `created:${task.teamName}:${task.id}`, suppressToast, diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index f5cc5e57..66bdb2a0 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -15,6 +15,24 @@ import type { TriggerColor } from '@shared/constants/triggerColors'; // Detected Error Types // ============================================================================= +/** + * Team notification event sub-types. + * Single source of truth — used by DetectedError, TeamNotificationPayload, and TEAM_NOTIFICATION_CONFIG. + */ +export type TeamEventType = + | 'rate_limit' + | 'lead_inbox' + | 'user_inbox' + | 'task_clarification' + | 'task_status_change' + | 'task_comment' + | 'task_created' + | 'all_tasks_completed' + | 'cross_team_message' + | 'schedule_completed' + | 'schedule_failed' + | 'team_launched'; + /** * Detected error from session JSONL files. * Used for notification display and deep linking to error locations. @@ -53,18 +71,7 @@ export interface DetectedError { /** Notification domain: 'error' (default/undefined) or 'team' */ category?: 'error' | 'team'; /** For team notifications: specific event sub-type */ - teamEventType?: - | 'rate_limit' - | 'lead_inbox' - | 'user_inbox' - | 'task_clarification' - | 'task_status_change' - | 'task_comment' - | 'task_created' - | 'all_tasks_completed' - | 'cross_team_message' - | 'schedule_completed' - | 'schedule_failed'; + teamEventType?: TeamEventType; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ dedupeKey?: string; /** Additional context */ @@ -280,6 +287,8 @@ export interface AppConfig { notifyOnAllTasksCompleted: boolean; /** Whether to show native OS notifications for cross-team messages */ notifyOnCrossTeamMessage: boolean; + /** Whether to show native OS notifications when a team finishes launching */ + notifyOnTeamLaunched: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index b3e343c7..b4d35a66 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -114,6 +114,7 @@ describe('configValidation', () => { 'notifyOnUserInbox', 'notifyOnClarifications', 'notifyOnStatusChange', + 'notifyOnTeamLaunched', 'statusChangeOnlySolo', ] as const)('accepts boolean %s toggle', (key) => { const resultOn = validateConfigUpdatePayload('notifications', { [key]: true }); @@ -134,6 +135,7 @@ describe('configValidation', () => { 'notifyOnUserInbox', 'notifyOnClarifications', 'notifyOnStatusChange', + 'notifyOnTeamLaunched', 'statusChangeOnlySolo', ] as const)('rejects non-boolean %s', (key) => { const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' }); diff --git a/test/main/utils/teamNotificationBuilder.test.ts b/test/main/utils/teamNotificationBuilder.test.ts index 7765f604..0dee4cee 100644 --- a/test/main/utils/teamNotificationBuilder.test.ts +++ b/test/main/utils/teamNotificationBuilder.test.ts @@ -98,6 +98,7 @@ describe('buildDetectedErrorFromTeam', () => { cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, + team_launched: { triggerName: 'Team Launched', triggerColor: 'green' }, }; for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) { From 71fcf88a48f6e6fa4b4cf93481226e13d9604ae3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 18 Mar 2026 09:36:16 +0200 Subject: [PATCH 06/13] feat: add process registration protocol text builder - Introduced a new function `buildProcessProtocolText` to generate context-free protocol text for background process registration. - Updated the `protocols` interface to include the new function, promoting code reuse across member and lead prompts. - Integrated the new protocol text into the team provisioning service for improved operational instructions. - Enhanced type definitions to reflect the addition of the new protocol functionality. --- agent-teams-controller/src/controller.js | 3 +++ agent-teams-controller/src/internal/tasks.js | 16 +++++++++++++--- mcp-server/src/agent-teams-controller.d.ts | 7 +++++++ .../services/team/TeamProvisioningService.ts | 5 ++++- src/types/agent-teams-controller.d.ts | 7 +++++++ .../TeamProvisioningServiceLiveMessages.test.ts | 4 ++++ .../team/TeamProvisioningServiceRelay.test.ts | 4 ++++ 7 files changed, 42 insertions(+), 4 deletions(-) diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index ece6bafc..9c4b1a6a 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -38,6 +38,9 @@ module.exports = { createController, createControllerContext, agentBlocks, + protocols: { + buildProcessProtocolText: tasks.buildProcessProtocolText, + }, tasks, kanban, review, diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index d4eff2ac..ae47db6c 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -443,8 +443,13 @@ function buildMemberTaskProtocol(teamName) { Failure to follow this protocol means the task board will show incorrect status.`); } -function buildMemberProcessProtocol(teamName) { - return wrapAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): +/** + * Raw process-registration protocol text (no agent-block wrapping). + * Shared between member briefing and lead provisioning prompt (DRY). + * Context-free — does NOT follow the (context, ...) convention. + */ +function buildProcessProtocolText(teamName) { + return `BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): 1. Launch with & to get PID: pnpm dev & 2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port): @@ -453,7 +458,11 @@ function buildMemberProcessProtocol(teamName) { { teamName: "${teamName}" } 4. When stopping a process, use MCP tool process_stop: { teamName: "${teamName}", pid: } -If verification in step 3 fails or the process is missing from the list, re-register it.`); +If verification in step 3 fails or the process is missing from the list, re-register it.`; +} + +function buildMemberProcessProtocol(teamName) { + return wrapAgentBlock(buildProcessProtocolText(teamName)); } function buildMemberFormattingProtocol() { @@ -593,6 +602,7 @@ module.exports = { setTaskStatus, softDeleteTask, startTask, + buildProcessProtocolText, memberBriefing, taskBriefing, unlinkTask, diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 149dc08b..92eb7333 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -98,4 +98,11 @@ declare module 'agent-teams-controller' { } export const agentBlocks: AgentBlocksApi; + + /** Context-free protocol text builders, shared across lead and member prompts. */ + export interface ProtocolsApi { + buildProcessProtocolText(teamName: string): string; + } + + export const protocols: ProtocolsApi; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ed847516..19355880 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -97,7 +97,7 @@ import type { } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); -const { createController } = agentTeamsControllerModule; +const { createController, protocols } = agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; @@ -569,6 +569,9 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", comment: "" }`, `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, ``, + `Process operations — use MCP tools directly:`, + protocols.buildProcessProtocolText(teamName), + ``, `Attachment storage modes (IMPORTANT):`, `- Default is copy (safe, robust).`, `- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`, diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index c5b8a8d9..0f726387 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -86,7 +86,14 @@ declare module 'agent-teams-controller' { crossTeam: ControllerCrossTeamApi; } + /** Context-free protocol text builders, shared across lead and member prompts. */ + export interface ProtocolsApi { + buildProcessProtocolText(teamName: string): string; + } + export function createController(options: ControllerContextOptions): AgentTeamsController; export const agentBlocks: AgentBlocksApi; + + export const protocols: ProtocolsApi; } diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 0f8b68ac..29ff6f7f 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -103,6 +103,10 @@ vi.mock('agent-teams-controller', () => ({ hoisted.sendInboxMessage(teamName, message), }, }), + protocols: { + buildProcessProtocolText: (teamName: string) => + `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`, + }, })); import type { TeamChangeEvent } from '@shared/types/team'; diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 355c4e89..4847ccb6 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -121,6 +121,10 @@ vi.mock('agent-teams-controller', () => ({ hoisted.sendInboxMessage(teamName, message), }, }), + protocols: { + buildProcessProtocolText: (teamName: string) => + `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`, + }, })); import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; From a61178105b38d893c3dfc7d2b55dda235c5bac19 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 18 Mar 2026 11:03:12 +0200 Subject: [PATCH 07/13] feat: add action mode protocol text builder for team members and leads - Introduced `buildActionModeProtocolText` function to generate context-free action mode protocol text, enhancing clarity for both leads and members. - Updated the `protocols` interface to include the new function, promoting code reuse across different components. - Refactored existing logic in `actionModeInstructions.ts` and `TeamProvisioningService.ts` to utilize the new protocol text builder, improving maintainability and consistency. - Enhanced type definitions to reflect the addition of the new protocol functionality. --- Cli-Proxy-API-Management-Center | 1 + agent-teams-controller/src/controller.js | 1 + agent-teams-controller/src/internal/tasks.js | 18 ++++++++++++++++-- mcp-server/src/agent-teams-controller.d.ts | 1 + .../services/team/TeamProvisioningService.ts | 11 ++++++++--- .../services/team/actionModeInstructions.ts | 19 ++++++++----------- src/types/agent-teams-controller.d.ts | 1 + ...eamProvisioningServiceLiveMessages.test.ts | 2 ++ .../team/TeamProvisioningServiceRelay.test.ts | 2 ++ 9 files changed, 40 insertions(+), 16 deletions(-) create mode 160000 Cli-Proxy-API-Management-Center diff --git a/Cli-Proxy-API-Management-Center b/Cli-Proxy-API-Management-Center new file mode 160000 index 00000000..3f1f6a13 --- /dev/null +++ b/Cli-Proxy-API-Management-Center @@ -0,0 +1 @@ +Subproject commit 3f1f6a132336d4a985af2c2142a553f47f4a1ef6 diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index 9c4b1a6a..987b92d9 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -39,6 +39,7 @@ module.exports = { createControllerContext, agentBlocks, protocols: { + buildActionModeProtocolText: tasks.buildActionModeProtocolText, buildProcessProtocolText: tasks.buildProcessProtocolText, }, tasks, diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index ae47db6c..c5c0a17e 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -349,7 +349,12 @@ function buildMemberLanguageInstruction(config) { return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`; } -function buildMemberActionModeProtocol() { +/** + * Raw action-mode protocol text parameterized by DELEGATE description. + * Shared between lead (actionModeInstructions.ts) and member (memberBriefing). + * Context-free — does NOT follow the (context, ...) convention. + */ +function buildActionModeProtocolText(delegateDescription) { return [ 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):', '- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.', @@ -359,10 +364,16 @@ function buildMemberActionModeProtocol() { '- Modes:', ' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.', ' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.', - ' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.', + ` - DELEGATE: ${delegateDescription}`, ].join('\n'); } +function buildMemberActionModeProtocol() { + return buildActionModeProtocolText( + 'Do not implement yourself. Pass the task with full context (what you know, what is needed) to your team lead or another teammate and let them handle it.' + ); +} + function buildMemberTaskProtocol(teamName) { return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 0. IMPORTANT ID RULE: @@ -458,6 +469,8 @@ function buildProcessProtocolText(teamName) { { teamName: "${teamName}" } 4. When stopping a process, use MCP tool process_stop: { teamName: "${teamName}", pid: } +5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister: + { teamName: "${teamName}", pid: } If verification in step 3 fails or the process is missing from the list, re-register it.`; } @@ -602,6 +615,7 @@ module.exports = { setTaskStatus, softDeleteTask, startTask, + buildActionModeProtocolText, buildProcessProtocolText, memberBriefing, taskBriefing, diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 92eb7333..188dd1c0 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -101,6 +101,7 @@ declare module 'agent-teams-controller' { /** Context-free protocol text builders, shared across lead and member prompts. */ export interface ProtocolsApi { + buildActionModeProtocolText(delegateDescription: string): string; buildProcessProtocolText(teamName: string): string; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 19355880..a87673c0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -99,6 +99,8 @@ import type { const logger = createLogger('Service:TeamProvisioning'); const { createController, protocols } = agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; +const MEMBER_DELEGATE_DESCRIPTION = + 'Do not implement yourself. Pass the task with full context (what you know, what is needed) to your team lead or another teammate and let them handle it.'; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_POLL_MS = 500; @@ -421,7 +423,7 @@ function buildMemberSpawnPrompt( const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` : ''; - const actionModeProtocol = buildActionModeProtocol(); + const actionModeProtocol = protocols.buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION); return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock} ${getAgentLanguageInstruction()} @@ -452,7 +454,10 @@ function buildReconnectMemberSpawnPrompt( const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` : ''; - const actionModeProtocol = indentMultiline(buildActionModeProtocol(), ' '); + const actionModeProtocol = indentMultiline( + protocols.buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION), + ' ' + ); return ` For "${member.name}": - prompt: You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock} @@ -676,7 +681,7 @@ Constraints: - If the request is ambiguous or still needs technical discovery, immediately create a coarse investigation/triage task for the best-fit teammate. That teammate owns the code inspection, scope refinement, and creation of any follow-up tasks needed for execution. - Only do lead-side research first if the human explicitly asked YOU for analysis/planning, or if there is genuinely no appropriate teammate to own the investigation. - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. -- When messaging "user" (the human): NEVER mention internal MCP tools, scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint} +- When messaging "user" (the human): write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint} ${teamCtlOps} diff --git a/src/main/services/team/actionModeInstructions.ts b/src/main/services/team/actionModeInstructions.ts index ce6f6869..1297853d 100644 --- a/src/main/services/team/actionModeInstructions.ts +++ b/src/main/services/team/actionModeInstructions.ts @@ -2,6 +2,13 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBloc import type { AgentActionMode } from '@shared/types'; +import * as agentTeamsControllerModule from 'agent-teams-controller'; + +const { protocols } = agentTeamsControllerModule; + +const LEAD_DELEGATE_DESCRIPTION = + 'Strict orchestration mode for leads. Delegate the work and any needed investigation to teammates, coordinate it, and do not implement or personally research it yourself unless you are truly in SOLO MODE.'; + const ACTION_MODE_BLOCKS: Record = { do: [ 'TURN ACTION MODE: DO', @@ -27,17 +34,7 @@ const ACTION_MODE_BLOCKS: Record = { }; export function buildActionModeProtocol(): string { - return [ - 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):', - '- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.', - '- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.', - '- Never silently broaden permissions beyond the selected mode.', - '- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.', - '- Modes:', - ' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.', - ' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.', - ' - DELEGATE: Strict orchestration mode for leads. Delegate the work and any needed investigation to teammates, coordinate it, and do not implement or personally research it yourself unless you are truly in SOLO MODE.', - ].join('\n'); + return protocols.buildActionModeProtocolText(LEAD_DELEGATE_DESCRIPTION); } export function buildActionModeAgentBlock(mode: AgentActionMode | undefined): string { diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 0f726387..d75b2c71 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -88,6 +88,7 @@ declare module 'agent-teams-controller' { /** Context-free protocol text builders, shared across lead and member prompts. */ export interface ProtocolsApi { + buildActionModeProtocolText(delegateDescription: string): string; buildProcessProtocolText(teamName: string): string; } diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 29ff6f7f..d9e68057 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -104,6 +104,8 @@ vi.mock('agent-teams-controller', () => ({ }, }), protocols: { + buildActionModeProtocolText: (delegate: string) => + `ACTION MODE PROTOCOL (mock, delegate: ${delegate})`, buildProcessProtocolText: (teamName: string) => `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`, }, diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 4847ccb6..38b65822 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -122,6 +122,8 @@ vi.mock('agent-teams-controller', () => ({ }, }), protocols: { + buildActionModeProtocolText: (delegate: string) => + `ACTION MODE PROTOCOL (mock, delegate: ${delegate})`, buildProcessProtocolText: (teamName: string) => `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`, }, From 0d0364cfd3b5f9bb2fd8f2feca2ce198af56d129 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 18 Mar 2026 14:01:21 +0200 Subject: [PATCH 08/13] feat: introduce MEMBER_DELEGATE_DESCRIPTION for action mode protocols - Added MEMBER_DELEGATE_DESCRIPTION constant to centralize the delegate instructions for action mode protocols, improving code maintainability. - Updated buildActionModeProtocolText to utilize the new constant, enhancing clarity and reducing duplication. - Enhanced messageStore and TeamProvisioningService to support filePath handling in attachments, improving metadata management. - Updated type definitions to reflect the addition of MEMBER_DELEGATE_DESCRIPTION in the protocols interface. --- agent-teams-controller/src/controller.js | 1 + agent-teams-controller/src/internal/messageStore.js | 3 +++ agent-teams-controller/src/internal/tasks.js | 8 +++++--- mcp-server/src/agent-teams-controller.d.ts | 1 + src/main/services/team/TeamProvisioningService.ts | 8 ++++---- src/main/services/team/TeamTaskReader.ts | 6 ++++++ src/types/agent-teams-controller.d.ts | 1 + 7 files changed, 21 insertions(+), 7 deletions(-) diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index 987b92d9..f48f29b7 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -40,6 +40,7 @@ module.exports = { agentBlocks, protocols: { buildActionModeProtocolText: tasks.buildActionModeProtocolText, + MEMBER_DELEGATE_DESCRIPTION: tasks.MEMBER_DELEGATE_DESCRIPTION, buildProcessProtocolText: tasks.buildProcessProtocolText, }, tasks, diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index c966edd7..9a88f8fe 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -45,6 +45,9 @@ function normalizeAttachments(attachments) { filename: String(item.filename || '').trim(), mimeType: String(item.mimeType || '').trim(), size: Number(item.size || 0), + ...(typeof item.filePath === 'string' && item.filePath.trim() + ? { filePath: item.filePath.trim() } + : {}), })) .filter((item) => item.id && item.filename && item.mimeType && Number.isFinite(item.size)); diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index c5c0a17e..f245664e 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -368,10 +368,11 @@ function buildActionModeProtocolText(delegateDescription) { ].join('\n'); } +const MEMBER_DELEGATE_DESCRIPTION = + 'Do not implement yourself. Pass the task with full context (what you know, what is needed) to your team lead or another teammate and let them handle it.'; + function buildMemberActionModeProtocol() { - return buildActionModeProtocolText( - 'Do not implement yourself. Pass the task with full context (what you know, what is needed) to your team lead or another teammate and let them handle it.' - ); + return buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION); } function buildMemberTaskProtocol(teamName) { @@ -616,6 +617,7 @@ module.exports = { softDeleteTask, startTask, buildActionModeProtocolText, + MEMBER_DELEGATE_DESCRIPTION, buildProcessProtocolText, memberBriefing, taskBriefing, diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 188dd1c0..fcf7b3e1 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -102,6 +102,7 @@ declare module 'agent-teams-controller' { /** Context-free protocol text builders, shared across lead and member prompts. */ export interface ProtocolsApi { buildActionModeProtocolText(delegateDescription: string): string; + MEMBER_DELEGATE_DESCRIPTION: string; buildProcessProtocolText(teamName: string): string; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a87673c0..9804c48f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -99,8 +99,6 @@ import type { const logger = createLogger('Service:TeamProvisioning'); const { createController, protocols } = agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; -const MEMBER_DELEGATE_DESCRIPTION = - 'Do not implement yourself. Pass the task with full context (what you know, what is needed) to your team lead or another teammate and let them handle it.'; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_POLL_MS = 500; @@ -423,7 +421,9 @@ function buildMemberSpawnPrompt( const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` : ''; - const actionModeProtocol = protocols.buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION); + const actionModeProtocol = protocols.buildActionModeProtocolText( + protocols.MEMBER_DELEGATE_DESCRIPTION + ); return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock} ${getAgentLanguageInstruction()} @@ -455,7 +455,7 @@ function buildReconnectMemberSpawnPrompt( ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}` : ''; const actionModeProtocol = indentMultiline( - protocols.buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION), + protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION), ' ' ); return ` For "${member.name}": diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 45b84829..d5091d05 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -253,6 +253,9 @@ export class TeamTaskReader { mimeType: String(a.mimeType).trim(), size: a.size, addedAt: a.addedAt, + ...('filePath' in a && typeof a.filePath === 'string' + ? { filePath: a.filePath } + : {}), })); return filtered.length > 0 ? filtered : undefined; })() @@ -288,6 +291,9 @@ export class TeamTaskReader { mimeType: String(a.mimeType).trim(), size: a.size, addedAt: a.addedAt, + ...(a.filePath != null && typeof a.filePath === 'string' + ? { filePath: a.filePath } + : {}), })) : undefined, reviewState: getReviewStateFromTask({ diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index d75b2c71..7c173299 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -89,6 +89,7 @@ declare module 'agent-teams-controller' { /** Context-free protocol text builders, shared across lead and member prompts. */ export interface ProtocolsApi { buildActionModeProtocolText(delegateDescription: string): string; + MEMBER_DELEGATE_DESCRIPTION: string; buildProcessProtocolText(teamName: string): string; } From 33ba4bdeabbadb7ae8c4476db16e9e1d22c16398 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 18 Mar 2026 14:59:56 +0200 Subject: [PATCH 09/13] feat: include comments in GlobalTask payload to restore notification functionality - Updated TeamDataService to include lightweight comment metadata in the GlobalTask payload, addressing issues with task comment notifications in the renderer. - Enhanced TeamProvisioningService with a stall watchdog to monitor CLI process responsiveness and emit warnings for extended periods of inactivity. - Modified CreateTeamDialog and LaunchTeamDialog to ensure default values for selected model and effort are set correctly, improving user experience during team creation and launch. --- src/main/services/team/TeamDataService.ts | 22 +- .../services/team/TeamProvisioningService.ts | 200 +++++++++++++++++- .../team/dialogs/CreateTeamDialog.tsx | 10 +- .../team/dialogs/LaunchTeamDialog.tsx | 12 +- 4 files changed, 232 insertions(+), 12 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 880ac553..aeb48a46 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -230,7 +230,27 @@ export class TeamDataService { needsClarification: task.needsClarification, deletedAt: task.deletedAt, reviewState, - // Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small + // IMPORTANT: comments MUST be included here (at least lightweight metadata). + // + // Previously comments were omitted from GlobalTask payload to keep IPC small. + // This silently broke task comment notifications in the renderer: the store's + // detectTaskCommentNotifications() compares oldTask.comments vs newTask.comments + // to find new comments and fire native OS toasts. Without comments in the payload, + // both counts were always 0 → newCommentCount <= oldCommentCount → every comment + // was silently skipped → "Task comment notifications" toggle had no effect. + // + // Fix: include lightweight comment metadata (id, author, truncated text for toast + // preview, createdAt, type). Full text and attachments are still omitted — those + // are loaded on-demand by the task detail view via team:getData. + comments: Array.isArray(task.comments) + ? task.comments.map((c) => ({ + id: c.id, + author: c.author, + text: c.text.slice(0, 120), + createdAt: c.createdAt, + type: c.type, + })) + : undefined, kanbanColumn, teamName: task.teamName, teamDisplayName: info.displayName, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9804c48f..4b215949 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -112,6 +112,9 @@ const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const FS_MONITOR_POLL_MS = 2000; const TASK_WAIT_FALLBACK_MS = 15_000; +const STALL_CHECK_INTERVAL_MS = 10_000; +const STALL_WARNING_THRESHOLD_MS = 45_000; +const STALL_WARNING_REPEAT_MS = 30_000; const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; @@ -120,6 +123,13 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_list_targets', 'cross_team_get_outbox', ]); +const HANDLED_STREAM_JSON_TYPES = new Set([ + 'user', + 'assistant', + 'control_request', + 'result', + 'system', +]); const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; const PREFLIGHT_PING_ARGS = [ '-p', @@ -217,6 +227,12 @@ interface ProvisioningRun { expectedMembers: string[]; request: TeamCreateRequest; lastLogProgressAt: number; + /** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */ + lastDataReceivedAt: number; + /** Stall watchdog interval handle. Cleared in cleanupRun(). */ + stallCheckHandle: NodeJS.Timeout | null; + /** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */ + apiErrorWarningEmitted: boolean; fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; waitingTasksSince: number | null; provisioningComplete: boolean; @@ -2358,6 +2374,127 @@ export class TeamProvisioningService { this.cleanupRun(run); } + /** + * Shows a non-fatal API error warning in the Live output section. + * Unlike failProvisioningWithApiError, does NOT kill the process — lets the SDK retry. + * Deduplicates: only the first warning per run is shown. + */ + private emitApiErrorWarning(run: ProvisioningRun, text: string): void { + if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return; + if (run.progress.state === 'failed' || run.cancelRequested) return; + if (run.apiErrorWarningEmitted) return; + + run.apiErrorWarningEmitted = true; + + const snippet = this.extractApiErrorSnippet(text); + const status = /api error:\s*(\d{3})\b/i.exec(text)?.[1] ?? null; + const label = status ? `API Error ${status}` : 'API Error'; + + const warningText = snippet + ? `**${label} — SDK is retrying**\n\n\`\`\`\n${snippet}\n\`\`\`\n\nОжидаем повторной попытки...` + : `**${label} — SDK is retrying**\n\nОжидаем повторной попытки...`; + + run.provisioningOutputParts.push(warningText); + run.progress.message = `${label} — SDK retrying...`; + emitLogsProgress(run); + // Prevent double-emit: the calling stderr/stdout handler will also try throttled emitLogsProgress + // after this returns. Updating lastLogProgressAt ensures the throttle check rejects it. + run.lastLogProgressAt = Date.now(); + } + + /** + * Starts a periodic watchdog that detects when the CLI process has produced + * no stdout/stderr data for an extended period. Pushes progressive warnings + * into provisioningOutputParts so they appear in the Live output section. + */ + private startStallWatchdog(run: ProvisioningRun): void { + if (run.stallCheckHandle) return; + + let lastWarningAt = 0; + + run.stallCheckHandle = setInterval(() => { + // try/catch: Node.js does NOT catch errors in setInterval callbacks — + // without this, an exception would silently kill the watchdog. + try { + if ( + run.provisioningComplete || + run.processKilled || + run.cancelRequested || + run.authRetryInProgress + ) { + this.stopStallWatchdog(run); + return; + } + + const now = Date.now(); + const silenceMs = now - run.lastDataReceivedAt; + + if (silenceMs < STALL_WARNING_THRESHOLD_MS) return; + if (lastWarningAt > 0 && now - lastWarningAt < STALL_WARNING_REPEAT_MS) return; + + lastWarningAt = now; + const silenceSec = Math.round(silenceMs / 1000); + + run.provisioningOutputParts.push(this.buildStallWarningText(silenceSec)); + const mins = Math.floor(silenceSec / 60); + const secs = silenceSec % 60; + const elapsed = mins > 0 ? `${mins} мин ${secs > 0 ? `${secs} сек` : ''}` : `${secs} сек`; + run.progress.message = `CLI не отвечает ${elapsed} — возможен rate limit`; + emitLogsProgress(run); + } catch (err) { + logger.error( + `[${run.teamName}] Stall watchdog error: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + }, STALL_CHECK_INTERVAL_MS); + } + + private stopStallWatchdog(run: ProvisioningRun): void { + if (run.stallCheckHandle) { + clearInterval(run.stallCheckHandle); + run.stallCheckHandle = null; + } + } + + private buildStallWarningText(silenceSec: number): string { + const mins = Math.floor(silenceSec / 60); + const secs = silenceSec % 60; + const elapsed = mins > 0 ? `${mins} мин ${secs > 0 ? `${secs} сек` : ''}` : `${secs} сек`; + + if (silenceSec < 60) { + return ( + `---\n\n` + + `**Ожидание ответа CLI** (тишина ${elapsed})\n\n` + + `Процесс запущен, но пока не выдаёт данных. ` + + `Это может быть вызвано задержкой API (rate limit / model cooldown) — ` + + `SDK выполняет повторные попытки автоматически.\n\n` + + `Ожидаем...` + ); + } + + if (silenceSec < 120) { + return ( + `---\n\n` + + `**Ожидание ответа CLI** (тишина ${elapsed})\n\n` + + `Процесс по-прежнему не отвечает. Вероятна задержка из-за rate limiting ` + + `(ошибка 429 / model cooldown). SDK автоматически повторяет запрос — ` + + `обычно это проходит в течение 1-3 минут.\n\n` + + `Можно отменить и попробовать позже, если ожидание затянется.` + ); + } + + return ( + `---\n\n` + + `**Длительное ожидание CLI** (тишина ${elapsed})\n\n` + + `Процесс молчит уже более ${mins} минут. Вероятные причины:\n` + + `- Rate limiting / model cooldown (429) — SDK повторяет автоматически\n` + + `- Перегрузка API сервера\n\n` + + `Рекомендуем отменить и попробовать через несколько минут.` + ); + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -2411,6 +2548,7 @@ export class TeamProvisioningService { run.timeoutHandle = null; } this.stopFilesystemMonitor(run); + this.stopStallWatchdog(run); if (run.child) { run.child.stdout?.removeAllListeners('data'); run.child.stderr?.removeAllListeners('data'); @@ -2429,6 +2567,7 @@ export class TeamProvisioningService { run.stderrLogLineBuf = ''; run.claudeLogsUpdatedAt = undefined; run.authFailureRetried = true; + run.apiErrorWarningEmitted = false; updateProgress(run, 'spawning', 'Auth failed — retrying after short delay'); run.onProgress(run.progress); @@ -2487,6 +2626,9 @@ export class TeamProvisioningService { // Reattach stderr handler this.attachStderrHandler(run); + run.lastDataReceivedAt = Date.now(); + this.startStallWatchdog(run); + // Restart filesystem monitor for createTeam (launch skips it) if (!run.isLaunch) { this.startFilesystemMonitor(run, run.request); @@ -2538,6 +2680,8 @@ export class TeamProvisioningService { let stdoutLineBuf = ''; child.stdout.on('data', (chunk: Buffer) => { + // Reset stall watchdog FIRST — any data (even partial JSON) means the CLI is alive. + run.lastDataReceivedAt = Date.now(); const text = chunk.toString('utf8'); this.appendCliLogs(run, 'stdout', text); run.stdoutBuffer += text; @@ -2573,7 +2717,9 @@ export class TeamProvisioningService { // Not valid JSON — check for auth failure in raw text output this.handleAuthFailureInOutput(run, trimmed, 'stdout'); if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) { - this.failProvisioningWithApiError(run, trimmed); + // Show warning but do NOT kill — the SDK may be retrying internally (e.g. 429 model_cooldown). + // If all retries fail, result.subtype="error" will catch it and kill then. + this.emitApiErrorWarning(run, trimmed); } } } @@ -2592,6 +2738,8 @@ export class TeamProvisioningService { if (!child?.stderr) return; child.stderr.on('data', (chunk: Buffer) => { + // Reset stall watchdog FIRST — any data (even partial JSON) means the CLI is alive. + run.lastDataReceivedAt = Date.now(); const text = chunk.toString('utf8'); this.appendCliLogs(run, 'stderr', text); run.stderrBuffer += text; @@ -2602,7 +2750,9 @@ export class TeamProvisioningService { // Detect auth failure early instead of waiting for 5-minute timeout this.handleAuthFailureInOutput(run, text, 'stderr'); if (this.hasApiError(text) && !this.isAuthFailureWarning(text, 'stderr')) { - this.failProvisioningWithApiError(run, text); + // Show warning but do NOT kill — the SDK may be retrying internally (e.g. 429 model_cooldown). + // If all retries fail, result.subtype="error" will catch it and kill then. + this.emitApiErrorWarning(run, text); } const currentTs = Date.now(); @@ -2679,6 +2829,9 @@ export class TeamProvisioningService { expectedMembers: request.members.map((member) => member.name), request, lastLogProgressAt: 0, + lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) + stallCheckHandle: null, + apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, isLaunch: false, @@ -2792,6 +2945,11 @@ export class TeamProvisioningService { this.attachStdoutHandler(run); this.attachStderrHandler(run); + // Reset AFTER spawn — not at run init — because async operations (buildProvisioningEnv, + // writeConfigFile) between init and spawn can take seconds, causing false stall warnings. + run.lastDataReceivedAt = Date.now(); + this.startStallWatchdog(run); + // Filesystem-based progress monitor: actively polls team files instead // of relying on stdout (which only arrives at the end in text mode). // When config + members + tasks are all present, kill the process early @@ -3043,6 +3201,9 @@ export class TeamProvisioningService { expectedMembers, request: syntheticRequest, lastLogProgressAt: 0, + lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) + stallCheckHandle: null, + apiErrorWarningEmitted: false, waitingTasksSince: null, provisioningComplete: false, isLaunch: true, @@ -3196,6 +3357,11 @@ export class TeamProvisioningService { this.attachStdoutHandler(run); this.attachStderrHandler(run); + // Reset AFTER spawn — not at run init — because async operations between init + // and spawn can take seconds, causing false stall warnings. + run.lastDataReceivedAt = Date.now(); + this.startStallWatchdog(run); + // For launch, skip the filesystem monitor — files (config, inboxes, tasks) // already exist from the previous run and would trigger immediate false // completion on the first poll. Rely on stream-json result.success instead. @@ -4865,6 +5031,23 @@ export class TeamProvisioningService { } } } + + // Catch-all: detect API errors in unrecognised message types. + // Guards against future protocol additions that carry error payloads + // (e.g. type: "error") which would otherwise be silently dropped. + if (typeof msg.type === 'string' && !HANDLED_STREAM_JSON_TYPES.has(msg.type)) { + const raw = JSON.stringify(msg); + logger.warn( + `[${run.teamName}] Unhandled stream-json type "${msg.type}": ${raw.slice(0, 300)}` + ); + if ( + !run.provisioningComplete && + this.hasApiError(raw) && + !this.isAuthFailureWarning(raw, 'stdout') + ) { + this.emitApiErrorWarning(run, raw); + } + } } /** @@ -5333,7 +5516,10 @@ export class TeamProvisioningService { if ( preCompleteText && this.hasApiError(preCompleteText) && - !this.isAuthFailureWarning(preCompleteText, 'pre-complete') + !this.isAuthFailureWarning(preCompleteText, 'pre-complete') && + // Skip if we already showed a warning for this error — the SDK had a chance to retry + // and the CLI reported success. Killing now would be a false positive. + !run.apiErrorWarningEmitted ) { this.failProvisioningWithApiError(run, preCompleteText); return; @@ -5352,6 +5538,7 @@ export class TeamProvisioningService { run.timeoutHandle = null; } this.stopFilesystemMonitor(run); + this.stopStallWatchdog(run); if (run.isLaunch) { await this.updateConfigPostLaunch( @@ -5811,6 +5998,7 @@ export class TeamProvisioningService { clearTimeout(run.timeoutHandle); run.timeoutHandle = null; } + this.stopStallWatchdog(run); if (run.silentUserDmForwardClearHandle) { clearTimeout(run.silentUserDmForwardClearHandle); run.silentUserDmForwardClearHandle = null; @@ -6016,6 +6204,12 @@ export class TeamProvisioningService { return; } + // IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above! + // During respawn, the old process exit fires but run.stallCheckHandle already + // points to the NEW process's watchdog. Stopping it here would kill the wrong timer. + // The authRetryInProgress guard returns early, keeping the new watchdog alive. + this.stopStallWatchdog(run); + // === Process exited AFTER provisioning completed === // This means the team went offline (crash, kill, or natural exit). if (run.provisioningComplete) { diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 36dfd18b..106a137e 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -255,7 +255,8 @@ export const CreateTeamDialog = ({ const [teamColor, setTeamColor] = useState(''); const [conflictDismissed, setConflictDismissed] = useState(false); const [selectedModel, setSelectedModelRaw] = useState(() => { - const stored = localStorage.getItem('team:lastSelectedModel') ?? ''; + const stored = localStorage.getItem('team:lastSelectedModel'); + if (stored === null) return 'opus'; return stored === '__default__' ? '' : stored; }); const [limitContext, setLimitContextRaw] = useState( @@ -264,9 +265,10 @@ export const CreateTeamDialog = ({ const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); - const [selectedEffort, setSelectedEffortRaw] = useState( - () => localStorage.getItem('team:lastSelectedEffort') ?? '' - ); + const [selectedEffort, setSelectedEffortRaw] = useState(() => { + const stored = localStorage.getItem('team:lastSelectedEffort'); + return stored === null ? 'medium' : stored; + }); // Advanced CLI section state (use teamName-derived key for localStorage) const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_'; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index c00c79e7..1947db81 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -157,15 +157,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [isSubmitting, setIsSubmitting] = useState(false); const [selectedModel, setSelectedModelRaw] = useState(() => { - const stored = localStorage.getItem('team:lastSelectedModel') ?? ''; + const stored = localStorage.getItem('team:lastSelectedModel'); + if (stored === null) return 'opus'; return stored === '__default__' ? '' : stored; }); const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); - const [selectedEffort, setSelectedEffortRaw] = useState( - () => localStorage.getItem('team:lastSelectedEffort') ?? '' - ); + const [selectedEffort, setSelectedEffortRaw] = useState(() => { + const stored = localStorage.getItem('team:lastSelectedEffort'); + return stored === null ? 'medium' : stored; + }); // --------------------------------------------------------------------------- // Launch-only state @@ -323,6 +325,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); + setSelectedModelRaw('opus'); + setSelectedEffortRaw('medium'); } setLocalError(null); From 8d97c53cb4087cf0b78686c230bb5dee2be40c71 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 18 Mar 2026 23:18:35 +0200 Subject: [PATCH 10/13] feat: add dependencies section to TaskDetailDialog for improved task management - Implemented a new section in TaskDetailDialog to display tasks that are blocked by or blocking the current task, enhancing visibility of task dependencies. - Added interactive buttons for each dependency, allowing users to quickly access related tasks. - Updated review information display to streamline task review context, improving user experience. --- .../team/dialogs/TaskDetailDialog.tsx | 181 +++++++++--------- 1 file changed, 88 insertions(+), 93 deletions(-) diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 0730d67a..194f91c3 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -818,6 +818,83 @@ export const TaskDetailDialog = ({ {/* Sections container with uniform spacing */}
+ {/* Dependencies */} + {blockedByIds.length > 0 || blocksIds.length > 0 ? ( +
+ {blockedByIds.length > 0 ? ( +
+ + + Blocked by + + {blockedByIds.map((id) => { + const depTask = taskMap.get(id); + const isCompleted = depTask?.status === 'completed'; + const label = depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(id)}`; + return ( + + + + + {label} + + ); + })} +
+ ) : null} + + {blocksIds.length > 0 ? ( +
+ + + Blocks + + {blocksIds.map((id) => { + const depTask = taskMap.get(id); + const isCompleted = depTask?.status === 'completed'; + const label = depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(id)}`; + return ( + + + + + {label} + + ); + })} +
+ ) : null} +
+ ) : null} + {/* Description */} ) : null} - {blockedByIds.length > 0 || - blocksIds.length > 0 || - relatedIds.length > 0 || - relatedByIds.length > 0 || - kanbanTaskState?.reviewer || - kanbanTaskState?.errorDescription ? ( + {/* Review info */} + {kanbanTaskState?.reviewer || kanbanTaskState?.errorDescription ? (
- {/* Dependencies */} - {blockedByIds.length > 0 ? ( -
- - - Blocked by +
+ {kanbanTaskState.reviewer ? ( + + Reviewer: {kanbanTaskState.reviewer} - {blockedByIds.map((id) => { - const depTask = taskMap.get(id); - const isCompleted = depTask?.status === 'completed'; - const label = depTask - ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` - : `#${deriveTaskDisplayId(id)}`; - return ( - - - - - {label} - - ); - })} -
- ) : null} - - {blocksIds.length > 0 ? ( -
- - - Blocks - - {blocksIds.map((id) => { - const depTask = taskMap.get(id); - const isCompleted = depTask?.status === 'completed'; - const label = depTask - ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` - : `#${deriveTaskDisplayId(id)}`; - return ( - - - - - {label} - - ); - })} -
- ) : null} - - {/* Review info */} - {kanbanTaskState?.reviewer || kanbanTaskState?.errorDescription ? ( -
- {kanbanTaskState.reviewer ? ( - - Reviewer: {kanbanTaskState.reviewer} - - ) : null} - {kanbanTaskState.errorDescription ? ( - - {kanbanTaskState.errorDescription} - - ) : null} -
- ) : null} + ) : null} + {kanbanTaskState.errorDescription ? ( + {kanbanTaskState.errorDescription} + ) : null} +
) : null} From 08f22121c6041362ce07bad27402a9430781ccf3 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 19 Mar 2026 12:47:00 +0200 Subject: [PATCH 11/13] feat: enhance error handling and notifications for API errors - Implemented a new mechanism to detect and notify users of API errors within team communications, improving error visibility. - Updated the ActivityItem and ThoughtBodyContent components to visually indicate API errors with distinct styling. - Enhanced the TaskDetailDialog and KanbanTaskCard components to improve user experience by providing clearer feedback on task dependencies and statuses. - Refactored the teams IPC module to include checks for API errors, ensuring timely notifications are sent to users. --- README.md | 15 ++++- src/main/ipc/teams.ts | 57 +++++++++++++++++++ .../components/team/activity/ActivityItem.tsx | 5 +- .../team/activity/LeadThoughtsGroup.tsx | 16 ++++-- .../team/activity/ThoughtBodyContent.tsx | 7 ++- .../team/dialogs/TaskDetailDialog.tsx | 12 ++-- .../components/team/kanban/KanbanTaskCard.tsx | 43 +++++++------- src/shared/utils/apiErrorDetector.ts | 13 +++++ 8 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 src/shared/utils/apiErrorDetector.ts diff --git a/README.md b/README.md index 50a1aea0..8b57c5f7 100644 --- a/README.md +++ b/README.md @@ -38,21 +38,34 @@ A new approach to task management with AI agent teams.
More features -
- **Task creation with attachments** — Simply send a message to the team lead with any attached images (planed all files). The lead will automatically create a fully described task and attach your files directly to the task for complete context. + - **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses + - **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks + - **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size. + - **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place + - **Zero-setup onboarding** — built-in Claude Code installation and authentication + - **Built-in code editor** — edit project files with Git support without leaving the app + - **Branch strategy** — choose via prompt: single branch or git worktree per agent + - **Team member stats** — global performance statistics per member + - **Attach code context** — reference files or snippets in messages, like in Cursor. You can also mention tasks using `#task-id`, or refer to another team with `@team-name` in your messages. + - **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur + - **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box + - **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost + - **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference +
## Installation diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index e0847b54..414edb55 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -70,6 +70,7 @@ import { } from '@shared/utils/cliArgsParser'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; +import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import crypto from 'crypto'; import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; @@ -155,6 +156,13 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; +/** + * In-memory set of API error message keys already processed. + * Independent of NotificationManager storage — survives notification deletion/pruning. + */ +const seenApiErrorKeys = new Set(); +const SEEN_API_ERROR_KEYS_MAX = 500; + /** * Check messages for rate limit indicators and fire notifications for new ones. * Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion) @@ -198,6 +206,53 @@ function checkRateLimitMessages( } } +/** + * Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications. + * Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey. + * Skips rate-limit messages (they have their own notification path). + */ +function checkApiErrorMessages( + messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], + teamName: string, + teamDisplayName: string, + projectPath?: string +): void { + for (const msg of messages) { + if (msg.from === 'user') continue; + if (!isApiErrorMessage(msg.text)) continue; + // Don't double-notify if it's also a rate limit message + if (isRateLimitMessage(msg.text)) continue; + + const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; + const dedupeKey = `api-error:${teamName}:${rawKey}`; + + if (seenApiErrorKeys.has(dedupeKey)) continue; + seenApiErrorKeys.add(dedupeKey); + + if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) { + const first = seenApiErrorKeys.values().next().value; + if (first) seenApiErrorKeys.delete(first); + } + + // Extract status code for summary + const statusMatch = msg.text.match(/^API Error:\s*(\d{3})/); + const statusCode = statusMatch?.[1] ?? '???'; + + void NotificationManager.getInstance() + .addTeamNotification({ + teamEventType: 'rate_limit', // reuse rate_limit type — closest fit + teamName, + teamDisplayName, + from: msg.from, + summary: `API Error ${statusCode}: ${msg.from}`, + body: msg.text.slice(0, 400), + dedupeKey, + projectPath, + }) + .catch(() => undefined); + } +} + let teamDataService: TeamDataService | null = null; let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; @@ -443,6 +498,7 @@ async function handleGetData( const live = provisioning.getLiveLeadProcessMessages(tn); if (live.length === 0) { checkRateLimitMessages(data.messages, tn, displayName, projectPath); + checkApiErrorMessages(data.messages, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive } }; } @@ -503,6 +559,7 @@ async function handleGetData( merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); checkRateLimitMessages(merged, tn, displayName, projectPath); + checkApiErrorMessages(merged, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive, messages: merged } }; } diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2ca3c554..2dfc1606 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -775,7 +775,10 @@ export const ActivityItem = memo( replyTaskRefs={message.taskRefs} /> ) : displayText ? ( -
+
{onReply ? ( diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 62c56aef..56f04966 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -17,6 +17,7 @@ import { areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; @@ -560,6 +561,9 @@ const LeadThoughtsGroupRowComponent = ({ return null; }, [thoughts]); + // Detect if any thought in this group is an API error + const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); + const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); const isManaged = collapseMode === 'managed'; @@ -719,8 +723,8 @@ const LeadThoughtsGroupRowComponent = ({ className="group rounded-md [overflow:clip]" style={{ backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG, - border: CARD_BORDER_STYLE, - borderLeft: `3px solid ${colors.border}`, + border: hasApiError ? '1px solid rgba(248, 113, 113, 0.3)' : CARD_BORDER_STYLE, + borderLeft: `3px solid ${hasApiError ? '#f87171' : colors.border}`, }} > {/* Header */} @@ -732,6 +736,7 @@ const LeadThoughtsGroupRowComponent = ({ 'flex select-none items-center gap-2 px-3 py-1.5', canToggleBodyVisibility ? 'cursor-pointer' : '', ].join(' ')} + style={hasApiError ? { backgroundColor: 'rgba(248, 113, 113, 0.08)' } : undefined} onClick={handleBodyToggle} onKeyDown={ canToggleBodyVisibility @@ -879,10 +884,13 @@ const LeadThoughtsGroupRowComponent = ({ ) : null} {isBodyVisible && !expanded && needsTruncation ? ( -
+
+ + + + + {label} + ); }; @@ -349,7 +352,7 @@ export const KanbanTaskCard = ({ {hasBlockedBy ? (
- + Blocked by diff --git a/src/shared/utils/apiErrorDetector.ts b/src/shared/utils/apiErrorDetector.ts new file mode 100644 index 00000000..c6683b2b --- /dev/null +++ b/src/shared/utils/apiErrorDetector.ts @@ -0,0 +1,13 @@ +/** + * Detects API error messages from Claude CLI output. + * Pattern: "API Error: " at the beginning of the text. + */ + +const API_ERROR_RE = /^API Error:\s*\d{3}/; + +/** + * Returns true if the message text starts with "API Error: ". + */ +export function isApiErrorMessage(text: string): boolean { + return API_ERROR_RE.test(text); +} From bec8a6184aca163c6c6176c394286a81b3f4b896 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 19 Mar 2026 13:35:51 +0200 Subject: [PATCH 12/13] fix: refine regex patterns and improve utility functions for mention handling - Updated regex patterns in chipUtils and mentionLinkify to enhance boundary detection for mentions. - Refactored taskChangeRequest to simplify earliest date calculation using array destructuring. - Improved taskReferenceUtils by replacing character boundary checks with a more concise regex. - Enhanced teamMessageFiltering to ensure boolean checks for message filtering conditions. - Adjusted urlMatchUtils to refine URL matching regex for better accuracy. - Updated crossTeam constants to include comments for regex patterns, improving code clarity. - Removed unused CommentAttachmentPayload type from api.ts to clean up type definitions. - Introduced McpInstallScope type for better type safety in mcp.ts. - Enhanced extensionNormalizers to improve URL normalization and added tests for parseGitHubOwnerRepo function. - Cleaned up pricing.ts by removing unnecessary eslint disable comments. - Added tests for new functionality in chipUtils and crossTeam constants, ensuring robust coverage. --- electron.vite.config.1773920150269.mjs | 105 ++++++++++++++++++ src/renderer/utils/chipUtils.ts | 3 +- src/renderer/utils/mentionLinkify.ts | 8 +- src/renderer/utils/taskChangeRequest.ts | 3 +- src/renderer/utils/taskReferenceUtils.ts | 2 +- src/renderer/utils/teamMessageFiltering.ts | 8 +- src/renderer/utils/urlMatchUtils.ts | 3 +- src/shared/constants/crossTeam.ts | 5 +- src/shared/types/api.ts | 1 - src/shared/types/extensions/mcp.ts | 6 +- src/shared/utils/extensionNormalizers.ts | 12 +- src/shared/utils/pricing.ts | 1 - src/shared/utils/toolSummary.ts | 4 +- test/renderer/utils/chipUtils.test.ts | 52 +++++++++ test/renderer/utils/mentionLinkify.test.ts | 65 +++++++++++ .../renderer/utils/taskReferenceUtils.test.ts | 70 ++++++++++++ test/renderer/utils/urlMatchUtils.test.ts | 84 ++++++++++++++ test/shared/constants/crossTeam.test.ts | 27 ++++- .../shared/utils/extensionNormalizers.test.ts | 33 ++++++ test/shared/utils/toolSummary.test.ts | 89 +++++++++++++++ 20 files changed, 558 insertions(+), 23 deletions(-) create mode 100644 electron.vite.config.1773920150269.mjs create mode 100644 test/renderer/utils/mentionLinkify.test.ts create mode 100644 test/renderer/utils/taskReferenceUtils.test.ts create mode 100644 test/renderer/utils/urlMatchUtils.test.ts create mode 100644 test/shared/utils/toolSummary.test.ts diff --git a/electron.vite.config.1773920150269.mjs b/electron.vite.config.1773920150269.mjs new file mode 100644 index 00000000..63a288e9 --- /dev/null +++ b/electron.vite.config.1773920150269.mjs @@ -0,0 +1,105 @@ +// electron.vite.config.ts +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team"; +var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8")); +var prodDeps = Object.keys(pkg.dependencies || {}); +var bundledDeps = prodDeps.filter((d) => d !== "node-pty" && d !== "agent-teams-controller"); +function nativeModuleStub() { + const STUB_ID = "\0native-stub"; + return { + name: "native-module-stub", + resolveId(source) { + if (source.endsWith(".node")) return STUB_ID; + return null; + }, + load(id) { + if (id === STUB_ID) return "export default {}"; + return null; + } + }; +} +var electron_vite_config_default = defineConfig({ + main: { + plugins: [ + externalizeDepsPlugin({ + exclude: bundledDeps + }), + nativeModuleStub() + ], + resolve: { + alias: { + "@main": resolve(__electron_vite_injected_dirname, "src/main"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@preload": resolve(__electron_vite_injected_dirname, "src/preload") + } + }, + build: { + outDir: "dist-electron/main", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"), + "team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts") + }, + output: { + // CJS format so bundled deps can use __dirname/require. + // Use .cjs extension since package.json has "type": "module". + format: "cjs", + entryFileNames: "[name].cjs", + // Set UV_THREADPOOL_SIZE before any module code runs. + // Must be in the banner because ESM→CJS hoists imports above top-level code. + // On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher; + // with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock. + banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}` + } + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()], + resolve: { + alias: { + "@preload": resolve(__electron_vite_injected_dirname, "src/preload"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main") + } + }, + build: { + outDir: "dist-electron/preload", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts") + }, + output: { + format: "cjs", + entryFileNames: "[name].js" + } + } + } + }, + renderer: { + optimizeDeps: { + include: ["@codemirror/language-data"] + }, + resolve: { + alias: { + "@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main") + } + }, + plugins: [react()], + build: { + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html") + } + } + } + } +}); +export { + electron_vite_config_default as default +}; diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts index 94d2cecc..e479c843 100644 --- a/src/renderer/utils/chipUtils.ts +++ b/src/renderer/utils/chipUtils.ts @@ -317,8 +317,7 @@ export function calculateMentionPositions( // 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; + if (!/[\s,.:;!?)\]}-]/.test(after)) continue; } matches.push({ item: suggestion, start: i, end, token: text.slice(i, end) }); i = end; diff --git a/src/renderer/utils/mentionLinkify.ts b/src/renderer/utils/mentionLinkify.ts index ca7466b4..24d51a38 100644 --- a/src/renderer/utils/mentionLinkify.ts +++ b/src/renderer/utils/mentionLinkify.ts @@ -25,11 +25,12 @@ export function linkifyMentionsInMarkdown( const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length); const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const pattern = new RegExp( + // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp `(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi' ); - return text.replace(pattern, (_match, prefix: string, name: string) => { + return text.replace(pattern, (_match: string, prefix: string, name: string) => { // Find the canonical name (case-insensitive lookup) const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; const color = memberColorMap.get(canonical) ?? ''; @@ -49,18 +50,19 @@ export function linkifyTeamMentionsInMarkdown( text: string, teamNames: ReadonlySet | readonly string[] ): string { - const names = Array.isArray(teamNames) ? teamNames : [...teamNames]; + const names: readonly string[] = Array.isArray(teamNames) ? teamNames : [...teamNames]; if (names.length === 0) return text; // Sort by name length descending for greedy matching const sorted = [...names].sort((a, b) => b.length - a.length); const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const pattern = new RegExp( + // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp `(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi' ); - return text.replace(pattern, (_match, prefix: string, name: string) => { + return text.replace(pattern, (_match: string, prefix: string, name: string) => { const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`; }); diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index 2a8a398c..e384deca 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -47,7 +47,8 @@ export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undef } if (sources.length === 0) return undefined; - const earliest = sources.reduce((a, b) => (a < b ? a : b)); + const [first, ...rest] = sources; + const earliest = rest.reduce((a, b) => (a < b ? a : b), first); const date = new Date(earliest); date.setTime(date.getTime() - TASK_SINCE_GRACE_MS); return date.toISOString(); diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts index d1ceef72..7426a8cd 100644 --- a/src/renderer/utils/taskReferenceUtils.ts +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -13,7 +13,7 @@ const ZERO_WIDTH_TO_BITS = new Map( function isAllowedTaskRefBoundary(char: string | undefined): boolean { if (!char) return true; - return !/[A-Za-z0-9_]/.test(char); + return !/\w/.test(char); } function buildSuggestionsByRef( diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index cbae55f1..4eac6397 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -33,14 +33,14 @@ export function filterTeamMessages( const hasTo = filter.to.size > 0; if (hasFrom && hasTo) { list = list.filter((m) => { - const fromMatch = m.from?.trim() && filter.from.has(m.from.trim()); - const toMatch = m.to?.trim() && filter.to.has(m.to.trim()); + const fromMatch = Boolean(m.from?.trim() && filter.from.has(m.from.trim())); + const toMatch = Boolean(m.to?.trim() && filter.to.has(m.to.trim())); return fromMatch && toMatch; }); } else if (hasFrom || hasTo) { list = list.filter((m) => { - if (hasFrom) return m.from?.trim() && filter.from.has(m.from.trim()); - if (hasTo) return m.to?.trim() && filter.to.has(m.to.trim()); + if (hasFrom) return Boolean(m.from?.trim() && filter.from.has(m.from.trim())); + if (hasTo) return Boolean(m.to?.trim() && filter.to.has(m.to.trim())); return true; }); } diff --git a/src/renderer/utils/urlMatchUtils.ts b/src/renderer/utils/urlMatchUtils.ts index 878164a9..2d4b24ce 100644 --- a/src/renderer/utils/urlMatchUtils.ts +++ b/src/renderer/utils/urlMatchUtils.ts @@ -4,9 +4,10 @@ export interface TextMatch { value: string; } -const URL_REGEX = /https?:\/\/[^\s]+/g; +const URL_REGEX = /https?:\/\/\S+/g; function trimUrlMatch(rawUrl: string): string { + // eslint-disable-next-line sonarjs/slow-regex -- trailing punctuation only, input bounded return rawUrl.replace(/[),.!?;:]+$/g, ''); } diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts index f4a949ce..c54decfd 100644 --- a/src/shared/constants/crossTeam.ts +++ b/src/shared/constants/crossTeam.ts @@ -38,7 +38,10 @@ function unescapeCrossTeamAttribute(value: string): string { function parseCrossTeamAttributes(raw: string): Map { const attrs = new Map(); - const matches = raw.matchAll(/([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g); + const matches = raw.matchAll( + /* eslint-disable-next-line sonarjs/slow-regex -- attr values bounded by quotes, trusted prefix input */ + /([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g + ); for (const match of matches) { const key = match[1]?.trim(); const value = match[2]; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9b5bb57f..f19ba5f7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -40,7 +40,6 @@ import type { AddMemberRequest, AddTaskCommentRequest, AttachmentFileData, - CommentAttachmentPayload, CreateTaskRequest, CrossTeamMessage, CrossTeamSendRequest, diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index 577bd32a..cc971734 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -100,10 +100,12 @@ export interface McpServerDiagnostic { // ── Install request (renderer → main, minimal trusted data) ──────────────── +export type McpInstallScope = 'local' | 'user' | 'project'; + export interface McpInstallRequest { registryId: string; // server ID from registry (NOT full catalog item) serverName: string; // user-chosen name for `claude mcp add` - scope: 'local' | 'user' | 'project'; + scope: McpInstallScope; projectPath?: string; // required for 'project' scope envValues: Record; headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag) @@ -113,7 +115,7 @@ export interface McpInstallRequest { export interface McpCustomInstallRequest { serverName: string; - scope: 'local' | 'user' | 'project'; + scope: McpInstallScope; projectPath?: string; installSpec: McpInstallSpec; // user provides directly envValues: Record; diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index e3f701e0..5f86ca7b 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -12,7 +12,11 @@ export function normalizeRepoUrl(url: string): string { return url .toLowerCase() .replace(/\.git$/, '') - .replace(/\/+$/, ''); + .replace( + /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, URL length bounded */ + /\/+$/, + '' + ); } /** @@ -112,7 +116,11 @@ export function parseGitHubOwnerRepo(url: string): { owner: string; repo: string const parts = parsed.pathname .replace(/^\//, '') .replace(/\.git$/, '') - .replace(/\/+$/, '') + .replace( + /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, pathname bounded */ + /\/+$/, + '' + ) .split('/'); if (parts.length < 2 || !parts[0] || !parts[1]) return null; return { owner: parts[0], repo: parts[1] }; diff --git a/src/shared/utils/pricing.ts b/src/shared/utils/pricing.ts index 7a175df1..bf504472 100644 --- a/src/shared/utils/pricing.ts +++ b/src/shared/utils/pricing.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line no-restricted-imports -- resources/ is outside src/, no alias available import pricingData from '../../../resources/pricing.json'; export interface LiteLLMPricing { diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index 779ce3a5..b2b490b2 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -34,7 +34,9 @@ export function parseToolSummary(summary: string | undefined): ToolSummaryData | if (!match) return null; const byName: Record = {}; for (const part of match[2].split(', ')) { - const m = /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part); + const m = + // eslint-disable-next-line security/detect-unsafe-regex -- part from split, bounded by summary + /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part); if (m) { byName[m[2]] = parseInt(m[1], 10); } else { diff --git a/test/renderer/utils/chipUtils.test.ts b/test/renderer/utils/chipUtils.test.ts index 6e55be71..dab34a3c 100644 --- a/test/renderer/utils/chipUtils.test.ts +++ b/test/renderer/utils/chipUtils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { chipToken } from '@renderer/types/inlineChip'; import { + calculateMentionPositions, createChipFromSelection, findChipBoundary, isInsideChip, @@ -251,3 +252,54 @@ describe('removeChipTokenFromText', () => { expect(removeChipTokenFromText(text, chip)).toBe('A\nB'); }); }); + +describe('calculateMentionPositions boundary regex', () => { + function makeTextarea(): HTMLTextAreaElement { + const ta = document.createElement('textarea'); + ta.style.cssText = 'font:16px monospace;width:400px;height:100px'; + document.body.appendChild(ta); + return ta; + } + + function makeMemberSuggestion(name: string) { + return { id: name, name, type: 'member' as const }; + } + + it('matches @mention when char after is boundary: space, comma, dot', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alice ', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice,', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice.', suggestions)).toHaveLength(1); + document.body.removeChild(ta); + }); + + it('matches @mention when char after is boundary: colon, semicolon, bang, question', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alice:', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice;', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice!', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice?', suggestions)).toHaveLength(1); + document.body.removeChild(ta); + }); + + it('matches @mention when char after is boundary: ), ], }, -', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alice)', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice]', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice}', suggestions)).toHaveLength(1); + expect(calculateMentionPositions(ta, '@Alice-', suggestions)).toHaveLength(1); + document.body.removeChild(ta); + }); + + it('does NOT match @mention when char after is word char (letter, digit)', () => { + const ta = makeTextarea(); + const suggestions = [makeMemberSuggestion('Alice')]; + expect(calculateMentionPositions(ta, '@Alicex', suggestions)).toHaveLength(0); + expect(calculateMentionPositions(ta, '@Alice1', suggestions)).toHaveLength(0); + expect(calculateMentionPositions(ta, '@Alice_', suggestions)).toHaveLength(0); + document.body.removeChild(ta); + }); +}); diff --git a/test/renderer/utils/mentionLinkify.test.ts b/test/renderer/utils/mentionLinkify.test.ts new file mode 100644 index 00000000..8ded692a --- /dev/null +++ b/test/renderer/utils/mentionLinkify.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + linkifyAllMentionsInMarkdown, + linkifyMentionsInMarkdown, + linkifyTeamMentionsInMarkdown, +} from '@renderer/utils/mentionLinkify'; + +describe('mentionLinkify', () => { + it('linkifies @member after space', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyMentionsInMarkdown('hello @Alice world', m); + expect(r).toContain('mention://'); + expect(r).toContain('Alice'); + expect(r).not.toBe('hello @Alice world'); + }); + + it('does NOT linkify @ in email', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyMentionsInMarkdown('email@test.com', m); + expect(r).toBe('email@test.com'); + }); + + it('linkifies @team after (', () => { + const r = linkifyTeamMentionsInMarkdown('(@TeamAlpha)', ['TeamAlpha']); + expect(r).toContain('team://'); + expect(r).toContain('TeamAlpha'); + }); + + it('linkifyAll applies both member and team', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyAllMentionsInMarkdown('Hi @Alice from @TeamX', m, ['TeamX']); + expect(r).toContain('mention://'); + expect(r).toContain('team://'); + }); + + it('linkifies @ after start of string', () => { + const m = new Map([['Alice', 'blue']]); + const r = linkifyMentionsInMarkdown('@Alice hello', m); + expect(r).toContain('mention://'); + }); + + it('linkifies @ after [ { (', () => { + const m = new Map([['Bob', 'red']]); + expect(linkifyMentionsInMarkdown('[@Bob]', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('{@Bob}', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('(@Bob)', m)).toContain('mention://'); + }); + + it('does NOT linkify @ when followed by word char', () => { + const m = new Map([['Alice', 'blue']]); + expect(linkifyMentionsInMarkdown('@AliceX', m)).toBe('@AliceX'); + expect(linkifyMentionsInMarkdown('@Alice123', m)).toBe('@Alice123'); + }); + + it('linkifies when followed by boundary: space, comma, dot, ), ], }', () => { + const m = new Map([['Alice', 'blue']]); + expect(linkifyMentionsInMarkdown('@Alice ', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice,', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice.', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice)', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice]', m)).toContain('mention://'); + expect(linkifyMentionsInMarkdown('@Alice}', m)).toContain('mention://'); + }); +}); diff --git a/test/renderer/utils/taskReferenceUtils.test.ts b/test/renderer/utils/taskReferenceUtils.test.ts new file mode 100644 index 00000000..bfdcabba --- /dev/null +++ b/test/renderer/utils/taskReferenceUtils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTaskLinkHref, + linkifyTaskIdsInMarkdown, + parseTaskLinkHref, +} from '@renderer/utils/taskReferenceUtils'; + +import type { TaskRef } from '@shared/types'; + +describe('taskReferenceUtils', () => { + describe('TASK_REF_REGEX and isAllowedTaskRefBoundary', () => { + it('linkifies #ref when preceded by boundary (space, start)', () => { + const taskRef: TaskRef = { + taskId: 't1', + displayId: 'task-1', + teamName: 'my-team', + }; + const r = linkifyTaskIdsInMarkdown('see #task-1 done', [taskRef]); + expect(r).toContain('task://'); + expect(r).toContain('[#task-1]'); + }); + + it('does NOT linkify #ref when preceded by word char', () => { + const taskRef: TaskRef = { + taskId: 't1', + displayId: 'task1', + teamName: 'my-team', + }; + const r = linkifyTaskIdsInMarkdown('x#task1', [taskRef]); + expect(r).toBe('x#task1'); + }); + + it('linkifies #ref with hyphen in id', () => { + const r = linkifyTaskIdsInMarkdown(' #abc-123 '); + expect(r).toContain('task://'); + }); + }); + + describe('buildTaskLinkHref and parseTaskLinkHref', () => { + it('roundtrips task ref', () => { + const ref: TaskRef = { + taskId: 'tid-1', + displayId: 'T-1', + teamName: 'team-a', + }; + const href = buildTaskLinkHref(ref); + expect(href).toContain('task://'); + expect(href).toContain('team='); + expect(href).toContain('display='); + + const parsed = parseTaskLinkHref(href); + expect(parsed).toEqual({ + taskId: 'tid-1', + teamName: 'team-a', + displayId: 'T-1', + }); + }); + + it('parseTaskLinkHref returns null for non-task URL', () => { + expect(parseTaskLinkHref('https://example.com')).toBeNull(); + expect(parseTaskLinkHref('mention://x')).toBeNull(); + }); + + it('parseTaskLinkHref handles task:// without query', () => { + const r = parseTaskLinkHref('task://tid-1'); + expect(r).toEqual({ taskId: 'tid-1' }); + }); + }); +}); diff --git a/test/renderer/utils/urlMatchUtils.test.ts b/test/renderer/utils/urlMatchUtils.test.ts new file mode 100644 index 00000000..a3ea0e20 --- /dev/null +++ b/test/renderer/utils/urlMatchUtils.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { + findUrlBoundary, + findUrlMatches, + removeUrlMatchFromText, +} from '@renderer/utils/urlMatchUtils'; + +describe('urlMatchUtils', () => { + describe('findUrlMatches URL_REGEX', () => { + it('matches http and https URLs', () => { + const m1 = findUrlMatches('see https://example.com'); + expect(m1).toHaveLength(1); + expect(m1[0].value).toBe('https://example.com'); + + const m2 = findUrlMatches('see http://foo.bar/path'); + expect(m2).toHaveLength(1); + expect(m2[0].value).toBe('http://foo.bar/path'); + }); + + it('matches URL with query and hash', () => { + const m = findUrlMatches('https://x.com?a=1#anchor'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://x.com?a=1#anchor'); + }); + + it('returns empty for text without URLs', () => { + expect(findUrlMatches('no url here')).toEqual([]); + expect(findUrlMatches('')).toEqual([]); + }); + + it('matches multiple URLs', () => { + const m = findUrlMatches('a https://a.com b https://b.com c'); + expect(m).toHaveLength(2); + expect(m[0].value).toBe('https://a.com'); + expect(m[1].value).toBe('https://b.com'); + }); + }); + + describe('trimUrlMatch trailing punctuation regex', () => { + it('strips trailing ), . ! ? ; :', () => { + const m = findUrlMatches('check (https://example.com).'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://example.com'); + }); + + it('strips trailing comma', () => { + const m = findUrlMatches('see https://x.com, and more'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://x.com'); + }); + + it('strips multiple trailing punctuation', () => { + const m = findUrlMatches('(https://x.com)...'); + expect(m).toHaveLength(1); + expect(m[0].value).toBe('https://x.com'); + }); + }); + + describe('findUrlBoundary', () => { + it('returns match when cursor inside URL', () => { + const text = 'go to https://example.com now'; + const m = findUrlBoundary(text, 12); + expect(m).not.toBeNull(); + expect(m!.value).toBe('https://example.com'); + }); + + it('returns null when cursor outside URL', () => { + const text = 'go to https://example.com now'; + expect(findUrlBoundary(text, 0)).toBeNull(); + expect(findUrlBoundary(text, 100)).toBeNull(); + }); + }); + + describe('removeUrlMatchFromText', () => { + it('removes URL from text', () => { + const text = 'see https://x.com here'; + const matches = findUrlMatches(text); + expect(matches).toHaveLength(1); + const result = removeUrlMatchFromText(text, matches[0]); + expect(result).toBe('see here'); + }); + }); +}); diff --git a/test/shared/constants/crossTeam.test.ts b/test/shared/constants/crossTeam.test.ts index 6d8351f7..04731701 100644 --- a/test/shared/constants/crossTeam.test.ts +++ b/test/shared/constants/crossTeam.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { parseCrossTeamPrefix, stripCrossTeamPrefix } from '@shared/constants/crossTeam'; +import { + formatCrossTeamPrefix, + parseCrossTeamPrefix, + stripCrossTeamPrefix, +} from '@shared/constants/crossTeam'; describe('crossTeam protocol helpers', () => { it('parses canonical cross-team prefix metadata', () => { @@ -19,8 +23,25 @@ describe('crossTeam protocol helpers', () => { it('strips canonical prefix from UI text', () => { expect( stripCrossTeamPrefix('\nHello') - ).toBe( - 'Hello' + ).toBe('Hello'); + }); + + it('parseCrossTeamAttributes regex: parses attr="value" pairs', () => { + const text = formatCrossTeamPrefix('team.user', 0, { + conversationId: 'c1', + replyToConversationId: 'c0', + }); + const parsed = parseCrossTeamPrefix(text + '\nbody'); + expect(parsed).not.toBeNull(); + expect(parsed!.from).toBe('team.user'); + expect(parsed!.conversationId).toBe('c1'); + expect(parsed!.replyToConversationId).toBe('c0'); + }); + + it('handles depth attribute', () => { + const parsed = parseCrossTeamPrefix( + '\nHi' ); + expect(parsed?.chainDepth).toBe(2); }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index ff28a938..ce40a00d 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -10,6 +10,7 @@ import { inferCapabilities, normalizeCategory, normalizeRepoUrl, + parseGitHubOwnerRepo, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -168,3 +169,35 @@ describe('sanitizeMcpServerName', () => { expect(sanitizeMcpServerName('Context7')).toBe('context7'); }); }); + +describe('parseGitHubOwnerRepo', () => { + it('extracts owner/repo from https URL', () => { + expect(parseGitHubOwnerRepo('https://github.com/owner/repo')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + it('strips .git suffix', () => { + expect(parseGitHubOwnerRepo('https://github.com/owner/repo.git')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + it('strips trailing slashes', () => { + expect(parseGitHubOwnerRepo('https://github.com/owner/repo/')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + it('returns null for non-GitHub URLs', () => { + expect(parseGitHubOwnerRepo('https://gitlab.com/owner/repo')).toBeNull(); + expect(parseGitHubOwnerRepo('https://example.com')).toBeNull(); + }); + + it('returns null for invalid URL', () => { + expect(parseGitHubOwnerRepo('not-a-url')).toBeNull(); + }); +}); diff --git a/test/shared/utils/toolSummary.test.ts b/test/shared/utils/toolSummary.test.ts new file mode 100644 index 00000000..2a243d52 --- /dev/null +++ b/test/shared/utils/toolSummary.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildToolSummary, + formatToolSummary, + formatToolSummaryFromMap, + parseToolSummary, +} from '@shared/utils/toolSummary'; + +describe('toolSummary', () => { + describe('parseToolSummary simple format regex', () => { + it('parses "3 tools"', () => { + const r = parseToolSummary('3 tools'); + expect(r).toEqual({ total: 3, byName: {} }); + }); + + it('parses "1 tool"', () => { + const r = parseToolSummary('1 tool'); + expect(r).toEqual({ total: 1, byName: {} }); + }); + + it('returns null for invalid format', () => { + expect(parseToolSummary('invalid')).toBeNull(); + expect(parseToolSummary('')).toBeNull(); + expect(parseToolSummary(undefined)).toBeNull(); + }); + }); + + describe('parseToolSummary legacy format regex', () => { + it('parses "3 tools (Read, 2 Edit)"', () => { + const r = parseToolSummary('3 tools (Read, 2 Edit)'); + expect(r).not.toBeNull(); + expect(r!.total).toBe(3); + expect(r!.byName).toEqual({ Read: 1, Edit: 2 }); + }); + + it('parses "1 tool (Bash)"', () => { + const r = parseToolSummary('1 tool (Bash)'); + expect(r).not.toBeNull(); + expect(r!.total).toBe(1); + expect(r!.byName).toEqual({ Bash: 1 }); + }); + + it('parses tool names with spaces "2 tools (2 Web Search)"', () => { + const r = parseToolSummary('2 tools (2 Web Search)'); + expect(r).not.toBeNull(); + expect(r!.total).toBe(2); + expect(r!.byName['Web Search']).toBe(2); + }); + }); + + describe('buildToolSummary', () => { + it('returns "1 tool" for single tool_use', () => { + const content = [{ type: 'tool_use', name: 'Read', input: {} }]; + expect(buildToolSummary(content)).toBe('1 tool'); + }); + + it('returns "3 tools" for multiple', () => { + const content = [ + { type: 'tool_use', name: 'Read', input: {} }, + { type: 'tool_use', name: 'Edit', input: {} }, + { type: 'tool_use', name: 'Read', input: {} }, + ]; + expect(buildToolSummary(content)).toBe('3 tools'); + }); + + it('returns undefined for empty', () => { + expect(buildToolSummary([])).toBeUndefined(); + }); + }); + + describe('formatToolSummary', () => { + it('formats singular and plural', () => { + expect(formatToolSummary({ total: 1, byName: {} })).toBe('1 tool'); + expect(formatToolSummary({ total: 2, byName: {} })).toBe('2 tools'); + }); + }); + + describe('formatToolSummaryFromMap', () => { + it('returns undefined for empty map', () => { + expect(formatToolSummaryFromMap(new Map())).toBeUndefined(); + }); + + it('formats from map', () => { + const m = new Map([['Read', 2], ['Edit', 1]]); + expect(formatToolSummaryFromMap(m)).toBe('3 tools'); + }); + }); +}); From 16f3fa51a3170ddd2e34fe4f16c25247878d0c41 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 19 Mar 2026 14:07:14 +0200 Subject: [PATCH 13/13] refactor: clean up code and improve readability across multiple files - Removed unnecessary eslint disable comments related to intentional mutations for clarity. - Updated import statements for consistency and organization. - Refactored regex patterns and utility functions in various components to enhance performance and maintainability. - Improved error handling and notifications for API errors in the teams IPC module. - Streamlined the handling of DOM mutations in the ChatHistory and other components for better readability. - Enhanced type definitions and added utility functions to improve code structure and type safety. --- electron.vite.config.1773920150269.mjs | 105 ------------------ src/main/ipc/teams.ts | 4 +- .../services/discovery/SubagentResolver.ts | 2 - .../infrastructure/CliInstallerService.ts | 10 +- src/main/services/team/TeamDataService.ts | 1 - .../services/team/TeamProvisioningService.ts | 2 - .../services/team/actionModeInstructions.ts | 3 +- src/main/utils/metadataExtraction.ts | 30 +++-- src/main/utils/teamNotificationBuilder.ts | 3 +- src/main/workers/team-fs-worker.ts | 5 +- src/renderer/components/chat/ChatHistory.tsx | 6 +- .../components/team/CliLogsRichView.tsx | 4 +- .../team/activity/ThoughtBodyContent.tsx | 2 +- .../team/dialogs/TaskCommentInput.tsx | 2 +- .../components/team/editor/EditorFileTree.tsx | 1 - .../components/ui/MentionableTextarea.tsx | 1 - .../components/ui/auto-resize-textarea.tsx | 1 - 17 files changed, 28 insertions(+), 154 deletions(-) delete mode 100644 electron.vite.config.1773920150269.mjs diff --git a/electron.vite.config.1773920150269.mjs b/electron.vite.config.1773920150269.mjs deleted file mode 100644 index 63a288e9..00000000 --- a/electron.vite.config.1773920150269.mjs +++ /dev/null @@ -1,105 +0,0 @@ -// electron.vite.config.ts -import { defineConfig, externalizeDepsPlugin } from "electron-vite"; -import react from "@vitejs/plugin-react"; -import { readFileSync } from "fs"; -import { resolve } from "path"; -var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team"; -var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8")); -var prodDeps = Object.keys(pkg.dependencies || {}); -var bundledDeps = prodDeps.filter((d) => d !== "node-pty" && d !== "agent-teams-controller"); -function nativeModuleStub() { - const STUB_ID = "\0native-stub"; - return { - name: "native-module-stub", - resolveId(source) { - if (source.endsWith(".node")) return STUB_ID; - return null; - }, - load(id) { - if (id === STUB_ID) return "export default {}"; - return null; - } - }; -} -var electron_vite_config_default = defineConfig({ - main: { - plugins: [ - externalizeDepsPlugin({ - exclude: bundledDeps - }), - nativeModuleStub() - ], - resolve: { - alias: { - "@main": resolve(__electron_vite_injected_dirname, "src/main"), - "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), - "@preload": resolve(__electron_vite_injected_dirname, "src/preload") - } - }, - build: { - outDir: "dist-electron/main", - rollupOptions: { - input: { - index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"), - "team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts") - }, - output: { - // CJS format so bundled deps can use __dirname/require. - // Use .cjs extension since package.json has "type": "module". - format: "cjs", - entryFileNames: "[name].cjs", - // Set UV_THREADPOOL_SIZE before any module code runs. - // Must be in the banner because ESM→CJS hoists imports above top-level code. - // On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher; - // with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock. - banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}` - } - } - } - }, - preload: { - plugins: [externalizeDepsPlugin()], - resolve: { - alias: { - "@preload": resolve(__electron_vite_injected_dirname, "src/preload"), - "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), - "@main": resolve(__electron_vite_injected_dirname, "src/main") - } - }, - build: { - outDir: "dist-electron/preload", - rollupOptions: { - input: { - index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts") - }, - output: { - format: "cjs", - entryFileNames: "[name].js" - } - } - } - }, - renderer: { - optimizeDeps: { - include: ["@codemirror/language-data"] - }, - resolve: { - alias: { - "@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"), - "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), - "@main": resolve(__electron_vite_injected_dirname, "src/main") - } - }, - plugins: [react()], - build: { - rollupOptions: { - input: { - index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html") - } - } - } - } -}); -export { - electron_vite_config_default as default -}; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 414edb55..2eee6d42 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -63,6 +63,7 @@ import { import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits'; +import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { extractFlagsFromHelp, extractUserFlags, @@ -70,7 +71,6 @@ import { } from '@shared/utils/cliArgsParser'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; -import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import crypto from 'crypto'; import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; @@ -235,7 +235,7 @@ function checkApiErrorMessages( } // Extract status code for summary - const statusMatch = msg.text.match(/^API Error:\s*(\d{3})/); + const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text); const statusCode = statusMatch?.[1] ?? '???'; void NotificationManager.getInstance() diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts index 3ba119fd..240e8e80 100644 --- a/src/main/services/discovery/SubagentResolver.ts +++ b/src/main/services/discovery/SubagentResolver.ts @@ -312,7 +312,6 @@ export class SubagentResolver { * Intentionally mutates the subagent in place for consistency with other resolution methods. */ private enrichSubagentFromTask(subagent: Process, taskCall: ToolCall): void { - /* eslint-disable no-param-reassign -- Mutation is intentional; subagent is enriched in place */ subagent.parentTaskId = taskCall.id; subagent.description = taskCall.taskDescription; subagent.subagentType = taskCall.taskSubagentType; @@ -323,7 +322,6 @@ export class SubagentResolver { if (teamName && memberName) { subagent.team = { teamName, memberName, memberColor: '' }; } - /* eslint-enable no-param-reassign -- End of intentional mutation block */ } /** diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 5240a51a..765a3204 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -329,8 +329,8 @@ export class CliInstallerService { loggedIn?: boolean; authMethod?: string; }; - result.authLoggedIn = auth.loggedIn === true; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object - result.authMethod = auth.authMethod ?? null; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.authLoggedIn = auth.loggedIn === true; + result.authMethod = auth.authMethod ?? null; logger.info( `Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` + (authAttempt > 1 ? ` (attempt ${authAttempt})` : '') @@ -347,7 +347,7 @@ export class CliInstallerService { logger.warn( `Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${getErrorMessage(err)}` ); - result.authLoggedIn = false; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.authLoggedIn = false; } } } @@ -378,13 +378,13 @@ export class CliInstallerService { private async fetchLatestVersion(result: CliInstallationStatus): Promise { try { const latestRaw = await fetchText(`${GCS_BASE}/latest`); - result.latestVersion = normalizeVersion(latestRaw); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.latestVersion = normalizeVersion(latestRaw); logger.info( `Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"` ); if (result.installedVersion && result.latestVersion) { - result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object + result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion); logger.info( `Update available: ${result.updateAvailable} (${result.installedVersion} → ${result.latestVersion})` ); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index aeb48a46..b8cdce82 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -652,7 +652,6 @@ export class TeamDataService { 2000 ); if (branch && branch !== leadBranch) { - // eslint-disable-next-line no-param-reassign -- intentional in-place enrichment member.gitBranch = branch; } } catch { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4b215949..07dfc7b3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; @@ -7586,4 +7585,3 @@ export class TeamProvisioningService { }); } } -/* eslint-enable no-param-reassign -- Re-enable after TeamProvisioningService class */ diff --git a/src/main/services/team/actionModeInstructions.ts b/src/main/services/team/actionModeInstructions.ts index 1297853d..ae6692a1 100644 --- a/src/main/services/team/actionModeInstructions.ts +++ b/src/main/services/team/actionModeInstructions.ts @@ -1,9 +1,8 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; +import * as agentTeamsControllerModule from 'agent-teams-controller'; import type { AgentActionMode } from '@shared/types'; -import * as agentTeamsControllerModule from 'agent-teams-controller'; - const { protocols } = agentTeamsControllerModule; const LEAD_DELEGATE_DESCRIPTION = diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index 8ec70183..f861401e 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -10,6 +10,7 @@ import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSys import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; +import type { Readable } from 'stream'; const logger = createLogger('Util:metadataExtraction'); @@ -29,6 +30,16 @@ function byteLen(chunk: string): number { return Buffer.byteLength(chunk, 'utf8'); } +function createStreamCleanup(rl: readline.Interface, fileStream: Readable): () => void { + let cleaned = false; + return (): void => { + if (cleaned) return; + cleaned = true; + rl.close(); + fileStream.destroy(); + }; +} + /** * Extract CWD (current working directory) from the first entry. * Used to get the actual project path from encoded directory names. @@ -58,17 +69,8 @@ export async function extractCwd( let bytes = 0; let timedOut = false; - let cleaned = false; - // Close readline FIRST so `for await` exits, then destroy the stream. - // Calling only stream.destroy() can leave readline hanging when it has - // a partial line buffered (e.g. a 400KB+ JSONL line read in 64KB chunks). - const cleanup = (): void => { - if (cleaned) return; - cleaned = true; - rl.close(); - fileStream.destroy(); - }; + const cleanup = createStreamCleanup(rl, fileStream); const timer = setTimeout(() => { timedOut = true; @@ -140,14 +142,8 @@ export async function extractFirstUserMessagePreview( let bytes = 0; let timedOut = false; - let cleaned = false; - const cleanup = (): void => { - if (cleaned) return; - cleaned = true; - rl.close(); - fileStream.destroy(); - }; + const cleanup = createStreamCleanup(rl, fileStream); const timer = setTimeout(() => { timedOut = true; diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index f038a588..40b5e810 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -5,9 +5,8 @@ * to convert domain-level team payloads into the unified notification format. */ -import { randomUUID } from 'crypto'; - import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { randomUUID } from 'crypto'; import type { DetectedError } from '../services/error/ErrorMessageBuilder'; import type { TriggerColor } from '@shared/constants/triggerColors'; diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index d02fe020..b5910cbb 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -241,14 +241,13 @@ function nowMs(): number { } function bumpSkipReason(reasons: Record, reason: string): void { - // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional reasons[reason] = (reasons[reason] || 0) + 1; } function pushSlowest(list: SlowEntry[], entry: SlowEntry, maxLen: number): void { list.push(entry); list.sort((a, b) => b.ms - a.ms); - // eslint-disable-next-line no-param-reassign -- truncate in-place is intentional + if (list.length > maxLen) list.length = maxLen; } @@ -734,10 +733,8 @@ async function readTasksDirForTeam( } function mergeTaskDiag(target: GetAllTasksDiag, source: TaskReadDiag): void { - // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional target.skipped += source.skipped; for (const [reason, count] of Object.entries(source.skipReasons)) { - // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional target.skipReasons[reason] = (target.skipReasons[reason] || 0) + count; } } diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index df86fd30..96a2eeb2 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -615,20 +615,18 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { container .querySelectorAll('mark[data-search-result="current"]') .forEach((prev) => { - /* eslint-disable no-param-reassign -- Directly mutating DOM element style/attributes is necessary for search result highlighting */ prev.setAttribute('data-search-result', 'match'); prev.style.backgroundColor = 'var(--highlight-bg-inactive)'; prev.style.color = 'var(--highlight-text-inactive)'; prev.style.boxShadow = ''; - /* eslint-enable no-param-reassign -- Re-enable after DOM mutations */ }); } - /* eslint-disable no-param-reassign -- Directly mutating DOM element style/attributes is necessary for current search result highlighting */ + el.setAttribute('data-search-result', 'current'); el.style.backgroundColor = 'var(--highlight-bg)'; el.style.color = 'var(--highlight-text)'; el.style.boxShadow = '0 0 0 1px var(--highlight-ring)'; - /* eslint-enable no-param-reassign -- Re-enable after DOM mutations */ + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 9cd465c8..993cfa15 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -105,10 +105,8 @@ function restoreViewport( ): void { if (viewport.mode === 'edge') { if (viewport.edge === 'newest') { - // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation container.scrollTop = order === 'newest-first' ? 0 : container.scrollHeight; } else { - // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation container.scrollTop = order === 'newest-first' ? container.scrollHeight : 0; } return; @@ -121,7 +119,7 @@ function restoreViewport( const containerRect = container.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); const currentOffset = elRect.top - containerRect.top; - // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation + container.scrollTop += currentOffset - viewport.offsetTop; } diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx index b14d7297..de9c61f4 100644 --- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx +++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx @@ -4,7 +4,6 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { CopyButton } from '@renderer/components/common/CopyButton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables'; -import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { areStringArraysEqual, @@ -12,6 +11,7 @@ import { areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; +import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { Reply } from 'lucide-react'; import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index f0c53fb7..bd07fa46 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -291,7 +291,7 @@ export const TaskCommentInput = ({ className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); - // eslint-disable-next-line no-param-reassign -- reset file input to allow re-selecting same file + e.target.value = ''; }} /> diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index 9c0a4271..3172ccab 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -583,7 +583,6 @@ const RootDropZone = React.forwardRef< (el: HTMLDivElement | null) => { setNodeRef(el); if (typeof ref === 'function') ref(el); - // eslint-disable-next-line no-param-reassign -- combining forwarded ref with droppable ref else if (ref) ref.current = el; }, [ref, setNodeRef] diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index c78e3ff9..a62d351c 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -371,7 +371,6 @@ export const MentionableTextarea = React.forwardRef