diff --git a/src/main/index.ts b/src/main/index.ts index eb0084e6..40db5d09 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -426,7 +426,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { return teamProvisioningService.relayLeadInboxMessages(teamName); }) .catch((e: unknown) => - logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`) + logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`) ); } } @@ -466,7 +466,9 @@ function wireFileWatcherEvents(context: ServiceContext): void { void teamDataService .notifyLeadOnTeammateTaskStart(teamName, taskId) .catch((e: unknown) => - logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`) + logger.warn( + `[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}` + ) ); } } catch { diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 5b6edb40..edc4da79 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -11,6 +11,7 @@ import { TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, + TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, @@ -21,6 +22,7 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, + TEAM_GET_TASK_ATTACHMENT, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -38,6 +40,7 @@ import { TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, + TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, @@ -51,9 +54,6 @@ import { TEAM_UPDATE_TASK_FIELDS, TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_STATUS, - TEAM_SAVE_TASK_ATTACHMENT, - TEAM_GET_TASK_ATTACHMENT, - TEAM_DELETE_TASK_ATTACHMENT, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; @@ -91,7 +91,6 @@ import type { } from '../services'; import type { AttachmentFileData, - AttachmentMediaType, AttachmentMeta, AttachmentPayload, CreateTaskRequest, @@ -105,13 +104,13 @@ import type { SendMessageResult, TaskAttachmentMeta, TaskComment, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, TeamData, - TeamClaudeLogsQuery, - TeamClaudeLogsResponse, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -1049,7 +1048,9 @@ async function handleSendMessage( if (isLeadRecipient && isAlive) { void provisioning .relayLeadInboxMessages(tn) - .catch((e: unknown) => logger.warn(`Relay after sendMessage failed for ${tn}: ${e}`)); + .catch((e: unknown) => + logger.warn(`Relay after sendMessage failed for ${tn}: ${String(e)}`) + ); } return result; @@ -2038,7 +2039,7 @@ async function handleAddTaskComment( vTask.value!, safeId, a.filename, - a.mimeType as AttachmentMediaType, + a.mimeType, a.base64Data ); savedAttachments.push(meta); @@ -2160,9 +2161,9 @@ async function handleSaveTaskAttachment( vTeam.value!, vTask.value!, safeAttId, - filename as string, - mimeType as AttachmentMediaType, - base64Data as string + filename, + mimeType, + base64Data ); // Write metadata into the task JSON await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta); @@ -2193,12 +2194,7 @@ async function handleGetTaskAttachment( } return wrapTeamHandler('getTaskAttachment', () => - taskAttachmentStore.getAttachment( - vTeam.value!, - vTask.value!, - safeAttId, - mimeType as AttachmentMediaType - ) + taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType) ); } @@ -2225,12 +2221,7 @@ async function handleDeleteTaskAttachment( } return wrapTeamHandler('deleteTaskAttachment', async () => { - await taskAttachmentStore.deleteAttachment( - vTeam.value!, - vTask.value!, - safeAttId, - mimeType as AttachmentMediaType - ); + await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType); // Remove metadata from task JSON await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); }); diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts index 7e81c5ee..3ba119fd 100644 --- a/src/main/services/discovery/SubagentResolver.ts +++ b/src/main/services/discovery/SubagentResolver.ts @@ -162,7 +162,7 @@ export class SubagentResolver { if (!firstUserMessage) return undefined; const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : ''; - const match = /]*\bteammate_id="([^"]+)"/.exec(text); + const match = /]*?\bteammate_id="([^"]+)"/.exec(text); return match?.[1]; } diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 308a7a5e..8b7d312e 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -97,8 +97,11 @@ export class FileWatcher extends EventEmitter { private pendingReprocess = new Set(); /** Flag to prevent reuse after disposal */ private disposed = false; - /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */ - private readonly instanceCreatedAt = Date.now(); + /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files). + * Floored to second granularity because filesystem birthtimeMs may have lower resolution + * than Date.now() — without this, a file created in the same millisecond-window could + * appear older than the watcher on some platforms (e.g. ext4 on Linux). */ + private readonly instanceCreatedAt = Math.floor(Date.now() / 1000) * 1000; constructor( dataCache: DataCache, diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 4112cc9b..e6b94118 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -449,16 +449,16 @@ export class ChangeExtractorService { const isError = erroredIds.has(toolUseId); if (toolName === 'Edit') { - const path = typeof input.file_path === 'string' ? input.file_path : ''; + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const oldString = typeof input.old_string === 'string' ? input.old_string : ''; const newString = typeof input.new_string === 'string' ? input.new_string : ''; const replaceAll = input.replace_all === true; - if (path) { - seenFiles.add(path); + if (targetPath) { + seenFiles.add(targetPath); snippets.push({ toolUseId, - filePath: path, + filePath: targetPath, toolName: 'Edit', type: 'edit', oldString, @@ -470,15 +470,15 @@ export class ChangeExtractorService { }); } } else if (toolName === 'Write') { - const path = typeof input.file_path === 'string' ? input.file_path : ''; + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const writeContent = typeof input.content === 'string' ? input.content : ''; - if (path) { - const isNew = !seenFiles.has(path); - seenFiles.add(path); + if (targetPath) { + const isNew = !seenFiles.has(targetPath); + seenFiles.add(targetPath); snippets.push({ toolUseId, - filePath: path, + filePath: targetPath, toolName: 'Write', type: isNew ? 'write-new' : 'write-update', oldString: '', @@ -490,11 +490,11 @@ export class ChangeExtractorService { }); } } else if (toolName === 'MultiEdit') { - const path = typeof input.file_path === 'string' ? input.file_path : ''; + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const edits = Array.isArray(input.edits) ? input.edits : []; - if (path) { - seenFiles.add(path); + if (targetPath) { + seenFiles.add(targetPath); for (const edit of edits) { if (!edit || typeof edit !== 'object') continue; const editObj = edit as Record; @@ -502,7 +502,7 @@ export class ChangeExtractorService { const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; snippets.push({ toolUseId, - filePath: path, + filePath: targetPath, toolName: 'MultiEdit', type: 'multi-edit', oldString, diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index d1de9a24..8cc26468 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -1,6 +1,7 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import * as fs from 'fs'; import * as path from 'path'; @@ -8,7 +9,6 @@ import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; -import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; const logger = createLogger('Service:TeamConfigReader'); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ec57667c..e44851fd 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -43,6 +43,7 @@ import type { ResolvedTeamMember, SendMessageRequest, SendMessageResult, + TaskAttachmentMeta, TaskComment, TeamConfig, TeamCreateConfigRequest, @@ -962,7 +963,7 @@ export class TeamDataService { summary: `Task #${task.id} started`, }); } catch (error) { - logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`); + logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${String(error)}`); } } @@ -993,7 +994,7 @@ export class TeamDataService { async addTaskAttachment( teamName: string, taskId: string, - meta: import('@shared/types').TaskAttachmentMeta + meta: TaskAttachmentMeta ): Promise { await this.taskWriter.addAttachment(teamName, taskId, meta); } @@ -1036,7 +1037,7 @@ export class TeamDataService { teamName: string, taskId: string, text: string, - attachments?: import('@shared/types').TaskAttachmentMeta[] + attachments?: TaskAttachmentMeta[] ): Promise { const comment = await this.taskWriter.addComment(teamName, taskId, text, { attachments, @@ -1051,8 +1052,6 @@ export class TeamDataService { const task = tasks.find((t) => t.id === taskId); const leadName = this.resolveLeadNameFromConfig(config); const owner = task?.owner?.trim() || null; - const normalizedOwner = owner?.toLowerCase() ?? null; - // Auto-clear needsClarification: "user" on UI comment // UI comments always have author "user" (TeamTaskWriter default) if (task?.needsClarification === 'user') { diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index d2e5a42d..34c2096e 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -1,3 +1,5 @@ +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; + import type { InboxMessage, MemberStatus, @@ -6,8 +8,6 @@ import type { TeamTaskWithKanban, } from '@shared/types'; -import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; - export class TeamMemberResolver { resolveMembers( config: TeamConfig, diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 1fb221ff..78492e35 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,5 +1,6 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import * as fs from 'fs'; import * as path from 'path'; @@ -7,8 +8,6 @@ import { atomicWriteAsync } from './atomicWrite'; import type { TeamMember } from '@shared/types'; -import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; - interface TeamMembersMetaFile { version: 1; members: TeamMember[]; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a86b54ce..2376468d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -60,7 +60,6 @@ const STDOUT_RING_LIMIT = 64 * 1024; const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const SHELL_ENV_TIMEOUT_MS = 12000; -// const CLI_PREPARE_TIMEOUT_MS = 10000; const PROBE_CACHE_TTL_MS = 10 * 60_000; const PREFLIGHT_TIMEOUT_MS = 60000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; @@ -1095,10 +1094,10 @@ export class TeamProvisioningService { return line; }; - const windowOldestToNewest = run.claudeLogLines + const lines = run.claudeLogLines .slice(oldestInclusive, newestExclusive) - .map(normalizeLine); - const lines = windowOldestToNewest.reverse(); + .map(normalizeLine) + .toReversed(); return { lines, total, @@ -1393,12 +1392,13 @@ export class TeamProvisioningService { private sanitizeCliSnippet(text: string): string { // Remove control characters that often show up as binary noise in CLI error payloads. // Preserve newlines/tabs for readability. - return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ''); + // eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- intentionally stripping control chars + return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } private extractApiErrorSnippet(text: string): string | null { const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text); - if (!match || match.index === undefined) return null; + if (match?.index === undefined) return null; const start = Math.max(0, match.index - 200); const end = Math.min(text.length, match.index + 4000); const raw = text.slice(start, end).trim(); @@ -2560,7 +2560,7 @@ export class TeamProvisioningService { void this.sentMessagesStore .appendMessage(teamName, relayMsg) .catch((e: unknown) => - logger.warn(`[${teamName}] sentMessagesStore persist failed: ${e}`) + logger.warn(`[${teamName}] sentMessagesStore persist failed: ${String(e)}`) ); this.teamChangeEmitter?.({ type: 'inbox', @@ -2781,7 +2781,7 @@ export class TeamProvisioningService { .appendMessage(run.teamName, msg) .catch((e: unknown) => logger.warn( - `[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${e}` + `[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${String(e)}` ) ); this.teamChangeEmitter?.({ @@ -3202,9 +3202,7 @@ export class TeamProvisioningService { } // Extract compact metadata for the system message - const meta = (msg as Record).compact_metadata as - | Record - | undefined; + const meta = msg.compact_metadata as Record | undefined; const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto'; const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null; const tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : ''; @@ -3323,7 +3321,7 @@ export class TeamProvisioningService { // 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: ${e}`) + logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`) ); // Solo teams have no teammate processes to resume work; kick off task execution @@ -3408,7 +3406,7 @@ export class TeamProvisioningService { // 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: ${e}`) + logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`) ); } @@ -4050,7 +4048,7 @@ export class TeamProvisioningService { private async cleanupCliAutoSuffixedMembers(teamName: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); - let removedFromConfig: string[] = []; + const removedFromConfig: string[] = []; try { const raw = await tryReadRegularFileUtf8(configPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, @@ -4098,7 +4096,7 @@ export class TeamProvisioningService { // best-effort } - let activeNamesForInboxCleanup: Set = new Set(); + let activeNamesForInboxCleanup = new Set(); try { const metaMembers = await this.membersMetaStore.getMembers(teamName); if (metaMembers.length > 0) { diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index ba9568f7..117ad118 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -258,7 +258,7 @@ function dropCliAutoSuffixedMembers( for (const key of keys) { const member = memberMap.get(key); const name = member?.name ?? ''; - const match = name.trim().match(/^(.+)-(\d+)$/); + const match = /^(.+)-(\d+)$/.exec(name.trim()); if (!match?.[1] || !match[2]) continue; const suffix = Number(match[2]); if (!Number.isFinite(suffix) || suffix < 2) continue; diff --git a/src/preload/index.ts b/src/preload/index.ts index 863c48d0..687132ad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,7 @@ import { TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, + TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, @@ -77,6 +78,7 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, + TEAM_GET_TASK_ATTACHMENT, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -91,12 +93,10 @@ import { TEAM_REMOVE_MEMBER, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, - TEAM_SAVE_TASK_ATTACHMENT, - TEAM_GET_TASK_ATTACHMENT, - TEAM_DELETE_TASK_ATTACHMENT, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, + TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, @@ -168,6 +168,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CommentAttachmentPayload, ConflictCheckResult, ContextInfo, CreateTaskRequest, @@ -193,7 +194,6 @@ import type { SshConnectionConfig, SshConnectionStatus, SshLastConnection, - CommentAttachmentPayload, TaskAttachmentMeta, TaskChangeSetV2, TaskComment, diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index 748b55cc..352cde15 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -12,11 +12,11 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; +import { formatPercentOfTotal } from '@renderer/utils/contextMath'; import { formatCostUsd } from '@shared/utils/costFormatting'; import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; -import { formatPercentOfTotal } from '@renderer/utils/contextMath'; import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 6866f0ad..2c956ef5 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -11,6 +11,7 @@ import { COLOR_SURFACE_OVERLAY, COLOR_TEXT_MUTED, } from '@renderer/constants/cssVariables'; +import { sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection'; import { FlatInjectionList } from './components/FlatInjectionList'; @@ -29,7 +30,6 @@ import { SECTION_TOOL_OUTPUTS, SECTION_USER_MESSAGES, } from './types'; -import { sumContextInjectionTokens } from '@renderer/utils/contextMath'; import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types'; import type { @@ -133,10 +133,7 @@ export const SessionContextPanel = ({ }, [injections]); // Calculate total tokens - const totalTokens = useMemo( - () => sumContextInjectionTokens(injections), - [injections] - ); + const totalTokens = useMemo(() => sumContextInjectionTokens(injections), [injections]); // Section token counts const claudeMdTokens = useMemo( diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 2dabb345..033d4c7f 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -28,6 +28,8 @@ import { } from '@shared/constants/triggerColors'; import { Wrench } from 'lucide-react'; +import { highlightQueryInText } from '../searchHighlightUtils'; + import { BaseItem, StatusDot } from './BaseItem'; import { formatDuration } from './baseItemHelpers'; import { @@ -38,7 +40,6 @@ import { ToolErrorDisplay, WriteToolViewer, } from './linkedTool'; -import { highlightQueryInText } from '../searchHighlightUtils'; import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; @@ -72,9 +73,14 @@ export const LinkedToolItem: React.FC = ({ const summary = getToolSummary(linkedTool.name, linkedTool.input); const summaryNode = searchQueryOverride && searchQueryOverride.trim().length > 0 - ? highlightQueryInText(summary, searchQueryOverride, `${linkedTool.id ?? linkedTool.name}:summary`, { - forceAllActive: true, - }) + ? highlightQueryInText( + summary, + searchQueryOverride, + `${linkedTool.id ?? linkedTool.name}:summary`, + { + forceAllActive: true, + } + ) : summary; const elementRef = useRef(null); diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index d9f9ab5d..2e0c88dd 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { MessageSquare } from 'lucide-react'; -import { MarkdownViewer } from '../viewers'; import { highlightQueryInText } from '../searchHighlightUtils'; +import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -42,9 +42,14 @@ export const TextItem: React.FC = ({ const fullContent = step.content.outputText ?? preview; const truncatedPreview = truncateText(preview, 60); const summary = searchQueryOverride - ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) + ? highlightQueryInText( + truncatedPreview, + searchQueryOverride, + `${markdownItemId ?? step.id}:summary`, + { + forceAllActive: true, + } + ) : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index c3cdafad..c74742ee 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { Brain } from 'lucide-react'; -import { MarkdownViewer } from '../viewers'; import { highlightQueryInText } from '../searchHighlightUtils'; +import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -42,9 +42,14 @@ export const ThinkingItem: React.FC = ({ const fullContent = step.content.thinkingText ?? preview; const truncatedPreview = truncateText(preview, 60); const summary = searchQueryOverride - ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) + ? highlightQueryInText( + truncatedPreview, + searchQueryOverride, + `${markdownItemId ?? step.id}:summary`, + { + forceAllActive: true, + } + ) : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 12ec37e2..34684f96 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -112,6 +112,7 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode return parts; } +// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types export function highlightQueryInText( text: string, query: string, diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 25923608..460640b0 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -4,7 +4,6 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; import { CODE_BG, CODE_BORDER, @@ -24,6 +23,7 @@ import { PROSE_TABLE_BORDER, PROSE_TABLE_HEADER_BG, } from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { FileText } from 'lucide-react'; diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index d7206e22..cf66c06f 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -26,6 +26,7 @@ export const SettingsView = (): React.JSX.Element | null => { // Consume pending section (avoid setState during render) useEffect(() => { if (pendingSettingsSection) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setActiveSection(pendingSettingsSection as SettingsSection); clearPendingSettingsSection(); } diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx index 02872b4e..dabe26d1 100644 --- a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx +++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx @@ -9,8 +9,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { json } from '@codemirror/lang-json'; -import { bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting } from '@codemirror/language'; -import { lintGutter, linter, type Diagnostic } from '@codemirror/lint'; +import { + bracketMatching, + foldGutter, + foldKeymap, + indentOnInput, + syntaxHighlighting, +} from '@codemirror/language'; +import { type Diagnostic, linter, lintGutter } from '@codemirror/lint'; import { search, searchKeymap } from '@codemirror/search'; import { EditorState } from '@codemirror/state'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; @@ -45,7 +51,7 @@ const jsonLinter = linter((view: EditorView) => { JSON.parse(text); } catch (e) { if (e instanceof SyntaxError) { - const match = e.message.match(/position (\d+)/); + const match = /position (\d+)/.exec(e.message); const pos = match ? parseInt(match[1], 10) : 0; const safePos = Math.min(pos, text.length); diagnostics.push({ @@ -163,61 +169,69 @@ export const ConfigEditorDialog = ({ if (!open) return; let destroyed = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setLoading(true); setSaveStatus('idle'); setJsonError(null); const init = async (): Promise => { - const config = await api.config.get(); - if (destroyed) return; + try { + const config = await api.config.get(); + if (destroyed) return; - const jsonText = JSON.stringify(config, null, 2); - initialConfigRef.current = jsonText; - setLoading(false); + const jsonText = JSON.stringify(config, null, 2); + initialConfigRef.current = jsonText; + setLoading(false); - // Wait for DOM render - requestAnimationFrame(() => { - if (destroyed || !editorRef.current) return; + // Wait for DOM render + requestAnimationFrame(() => { + if (destroyed || !editorRef.current) return; - // Clean up existing view - if (viewRef.current) { - viewRef.current.destroy(); - viewRef.current = null; - } + // Clean up existing view + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } - const state = EditorState.create({ - doc: jsonText, - extensions: [ - lineNumbers(), - highlightActiveLineGutter(), - highlightActiveLine(), - history(), - foldGutter(), - indentOnInput(), - bracketMatching(), - json(), - syntaxHighlighting(oneDarkHighlightStyle), - jsonLinter, - lintGutter(), - search(), - keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]), - baseEditorTheme, - configEditorTheme, - EditorView.updateListener.of((update) => { - if (update.docChanged) { - const text = update.state.doc.toString(); - scheduleSave(text); - } - }), - ], + const state = EditorState.create({ + doc: jsonText, + extensions: [ + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + history(), + foldGutter(), + indentOnInput(), + bracketMatching(), + json(), + syntaxHighlighting(oneDarkHighlightStyle), + jsonLinter, + lintGutter(), + search(), + keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]), + baseEditorTheme, + configEditorTheme, + // eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const text = update.state.doc.toString(); + scheduleSave(text); + } + }), + ], + }); + + const view = new EditorView({ + state, + parent: editorRef.current, + }); + viewRef.current = view; }); - - const view = new EditorView({ - state, - parent: editorRef.current, - }); - viewRef.current = view; - }); + } catch (e) { + if (destroyed) return; + setLoading(false); + setJsonError(e instanceof Error ? e.message : 'Failed to load config'); + } }; void init(); diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index b160e5d8..f32108a1 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -250,6 +250,7 @@ export const GlobalTaskList = ({ // Reset showArchived when archive becomes empty useEffect(() => { if (showArchived && !hasArchivedTasks) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setShowArchived(false); } }, [showArchived, hasArchivedTasks]); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 6e72fe9e..2fc40d9b 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -96,6 +96,7 @@ export const SidebarTaskItem = ({ // Reset edit value when renaming starts useEffect(() => { if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setEditValue(displaySubject); } }, [isRenaming, displaySubject]); diff --git a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx index 179d8c9b..aeeb70e6 100644 --- a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx +++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx @@ -127,12 +127,26 @@ export const ClaudeLogsFilterPopover = ({ Stream

-
@@ -143,16 +157,37 @@ export const ClaudeLogsFilterPopover = ({ Content

-
@@ -176,4 +211,3 @@ export const ClaudeLogsFilterPopover = ({ ); }; - diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 0a20eaa5..d8cfa28e 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -6,17 +6,19 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { Search, Terminal, X } from 'lucide-react'; -import { CollapsibleTeamSection } from './CollapsibleTeamSection'; -import { CliLogsRichView } from './CliLogsRichView'; import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; +import { CliLogsRichView } from './CliLogsRichView'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; -import type { TeamClaudeLogsResponse } from '@shared/types'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; +import type { TeamClaudeLogsResponse } from '@shared/types'; const PAGE_SIZE = 100; const POLL_MS = 2000; const ONLINE_WINDOW_MS = 10_000; +type StreamType = 'stdout' | 'stderr'; + interface ClaudeLogsSectionProps { teamName: string; } @@ -35,9 +37,9 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string { const chronological = [...linesNewestFirst].reverse(); const out: string[] = []; - let lastStream: 'stdout' | 'stderr' | null = null; + let lastStream: StreamType | null = null; - const pushMarker = (stream: 'stdout' | 'stderr'): void => { + const pushMarker = (stream: StreamType): void => { if (lastStream === stream) return; lastStream = stream; out.push(stream === 'stdout' ? '[stdout]' : '[stderr]'); @@ -82,8 +84,8 @@ function filterStreamJsonText( const q = queryRaw.trim().toLowerCase(); const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n'); - let currentStream: 'stdout' | 'stderr' | null = null; - let lastEmittedStream: 'stdout' | 'stderr' | null = null; + let currentStream: StreamType | null = null; + let lastEmittedStream: StreamType | null = null; const out: string[] = []; const emitMarker = (): void => { diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 59efa480..b168d71d 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -49,6 +49,7 @@ function useElapsedTimer(startedAt?: string, isRunning = true): string | null { useEffect(() => { if (!startedAt) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setElapsedSeconds(null); return; } @@ -201,7 +202,8 @@ export const ProvisioningProgressBlock = ({ variant="secondary" className={cn( 'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal', - isDone && 'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]', + isDone && + 'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]', isCurrent && 'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]' )} diff --git a/src/renderer/components/team/RoleSelect.tsx b/src/renderer/components/team/RoleSelect.tsx index e7f70a28..4e091f14 100644 --- a/src/renderer/components/team/RoleSelect.tsx +++ b/src/renderer/components/team/RoleSelect.tsx @@ -3,17 +3,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; -import { - Blocks, - BookOpen, - Bug, - Check, - Code2, - FileText, - Pencil, - Shield, - Zap, -} from 'lucide-react'; +import { Blocks, BookOpen, Bug, Check, Code2, FileText, Pencil, Shield, Zap } from 'lucide-react'; import type { ComboboxOption } from '@renderer/components/ui/combobox'; import type { LucideIcon } from 'lucide-react'; @@ -61,13 +51,14 @@ const roleOptions: ComboboxOption[] = [ { value: CUSTOM_ROLE, label: 'Custom role...' }, ]; +// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure const renderRoleOption = (option: ComboboxOption, isSelected: boolean): React.ReactNode => { const Icon = option.value === CUSTOM_ROLE ? CUSTOM_ICON : option.value === NO_ROLE ? null - : ROLE_ICONS[option.value] ?? null; + : (ROLE_ICONS[option.value] ?? null); return ( <> diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e9efd55e..fe8b7bb9 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,9 +1,9 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; +import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Button } from '@renderer/components/ui/button'; -import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { Dialog, DialogContent, @@ -15,10 +15,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; +import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { useTabUI } from '@renderer/hooks/useTabUI'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; @@ -73,8 +73,8 @@ import { MemberList } from './members/MemberList'; import { MessageComposer } from './messages/MessageComposer'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; -import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ClaudeLogsSection } from './ClaudeLogsSection'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -222,7 +222,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele clearProvisioningError, isTeamProvisioning, leadActivityByTeam, - leadContextByTeam, refreshTeamData, kanbanFilterQuery, clearKanbanFilter, @@ -265,7 +264,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), leadActivityByTeam: s.leadActivityByTeam, - leadContextByTeam: s.leadContextByTeam, refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, @@ -366,15 +364,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele // Lead session context panel (reuses the same session context pipeline for exact stats) const leadSessionId = data?.config.leadSessionId ?? null; - const leadTabData = useStore( - useShallow((s) => (tabId ? s.tabSessionData[tabId] : null)) - ); + const leadTabData = useStore(useShallow((s) => (tabId ? s.tabSessionData[tabId] : null))); const leadSessionDetail = leadTabData?.sessionDetail ?? null; const leadConversation = leadTabData?.conversation ?? null; const leadSessionContextStats = leadTabData?.sessionContextStats ?? null; const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null; const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false; - const leadSessionLoaded = Boolean(leadSessionId && leadSessionDetail?.session?.id === leadSessionId); + const leadSessionLoaded = Boolean( + leadSessionId && leadSessionDetail?.session?.id === leadSessionId + ); const leadSubagentCostUsd = useMemo(() => { const processes = leadSessionDetail?.processes; @@ -382,8 +380,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); return total > 0 ? total : undefined; }, [leadSessionDetail?.processes]); - const leadContextPercent = leadContextByTeam[teamName]?.percent; - const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; @@ -405,7 +401,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele if (!targetAiGroupId) { const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai'); if (lastAiItem?.type !== 'ai') { - return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; + return { + allContextInjections: [] as ContextInjection[], + lastAiGroupTotalTokens: undefined, + }; } targetAiGroupId = lastAiItem.group.id; } @@ -435,7 +434,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; - }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]); + }, [ + leadSessionLoaded, + leadSessionContextStats, + leadConversation, + selectedContextPhase, + leadSessionPhaseInfo, + ]); const visibleContextTokens = useMemo( () => sumContextInjectionTokens(allContextInjections), @@ -927,438 +932,864 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele )} -
- {headerColorSet ? ( -
- ) : null}
-
-

- {data.config.name} -

-
-
- {data.isAlive && ( + {headerColorSet ? ( +
+ ) : null} +
+
+

+ {data.config.name} +

+
+
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + Edit team + - Stop team + Delete team - )} - - - - - Edit team - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} - {(data.config.projectPath || leadBranch) && ( -
- {data.config.projectPath && ( - - - - {formatProjectPath(data.config.projectPath)} - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} - {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} -
- )} - {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
- - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - + {data.config.description} +

+ )} + {(data.config.projectPath || leadBranch) && ( +
+ {data.config.projectPath && ( + + + + {formatProjectPath(data.config.projectPath)} + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + + + {leadBranch} + + )} + {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )}
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( -
- - - Team is offline - - + )} + {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()}
- ) : null} -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. -
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ - - } - > - + + Team is offline + + +
+ ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ + + } + > + { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={(member) => { + openCreateTaskDialog('', '', member.name); + }} + onOpenTask={(task) => setSelectedTask(task)} + /> + + + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ + + } + > + + + setKanbanSearch(e.target.value)} + className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" + /> + {kanbanSearch && ( + + + + + Clear search + + )} +
+ } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }} + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTask(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task #${taskId} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task #${taskId} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` + : ''; + await api.teams.processSend( + teamName, + `Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.add('ring-2', 'ring-blue-400/50'); + setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); + } + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + + + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + + + } + badge={filteredMessages.length} + secondaryBadge={ + filteredMessages.length > 0 && messagesUnreadCount > 0 + ? messagesUnreadCount + : undefined + } + headerExtra={ + + + + + Desktop notifications plugin + + } + defaultOpen + action={ +
+ {messagesUnreadCount > 0 && ( + + + + + Mark all as read + + )} +
+ + setMessagesSearchQuery(e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> + {messagesSearchQuery && ( + + )} +
+ +
+ } + > + { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + }); + }} + /> + + + { + openCreateTaskDialog(subject, description); + }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }} + onMessageVisible={handleMessageVisible} + onRestartTeam={() => setLaunchDialogOpen(true)} + onTaskIdClick={(taskId) => { + const task = taskMap.get(taskId); + if (task) setSelectedTask(task); + }} + /> +
+ + setRequestChangesTaskId(null)} + onSubmit={(comment) => { + if (!requestChangesTaskId) { + return; + } + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + }); + setRequestChangesTaskId(null); + } catch { + // error state is handled in the store and shown in the view + } + })(); + }} + /> + + { - setSendDialogRecipient(member.name); + onClose={() => setSelectedMember(null)} + onSendMessage={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + setSendDialogRecipient(name || undefined); setSendDialogDefaultText(undefined); setSendDialogDefaultChip(undefined); setReplyQuote(undefined); setSendDialogOpen(true); }} - onAssignTask={(member) => { - openCreateTaskDialog('', '', member.name); + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + openCreateTaskDialog('', '', name); + }} + onTaskClick={(task) => { + setSelectedMember(null); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + setSelectedMember(null); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); }} - onOpenTask={(task) => setSelectedTask(task)} /> - - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ - - } - > - - - setKanbanSearch(e.target.value)} - className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" - /> - {kanbanSearch && ( - - - - - Clear search - - )} -
- } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTask(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task #${taskId} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` - : ''; - await api.teams.processSend( - teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` - ); - } - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); + tasks={data.tasks} + isTeamAlive={data.isAlive && !isTeamProvisioning} + defaultSubject={createTaskDialog.defaultSubject} + defaultDescription={createTaskDialog.defaultDescription} + defaultOwner={createTaskDialog.defaultOwner} + defaultStartImmediately={createTaskDialog.defaultStartImmediately} + defaultChip={createTaskDialog.defaultChip} + onClose={closeCreateTaskDialog} + onSubmit={handleCreateTask} + submitting={creatingTask} + /> - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task #${taskId} cancelled`, - }); - } catch { - // best-effort - } - } + setEditDialogOpen(false)} + onSaved={() => void selectTeam(teamName)} + /> - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { + m.name)} + existingMembers={data.members} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(name, role, workflow) => { + setAddingMemberLoading(true); void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await addMember(teamName, { name, role, workflow }); + setAddMemberDialogOpen(false); } catch { - // error via store + // error shown via store + } finally { + setAddingMemberLoading(false); } })(); }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages will + be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { member, text, summary, attachments }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -1366,476 +1797,55 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store + } + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} /> - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> + + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open ? {} : { initialFilePath: undefined }), + })) } - defaultOpen - > - - - )} - - - - } - badge={filteredMessages.length} - secondaryBadge={ - filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined - } - headerExtra={ - - - - - Desktop notifications plugin - - } - defaultOpen - action={ -
- {messagesUnreadCount > 0 && ( - - - - - Mark all as read - - )} -
- - setMessagesSearchQuery(e.target.value)} - onPointerDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" - /> - {messagesSearchQuery && ( - - )} -
- -
- } - > - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - }); - }} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} /> - - - { - openCreateTaskDialog(subject, description); - }} - onReplyToMessage={(message) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }} - onMessageVisible={handleMessageVisible} - onRestartTeam={() => setLaunchDialogOpen(true)} - onTaskIdClick={(taskId) => { - const task = taskMap.get(taskId); - if (task) setSelectedTask(task); - }} - /> -
- - setRequestChangesTaskId(null)} - onSubmit={(comment) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - setSelectedMember(null)} - onSendMessage={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - openCreateTaskDialog('', '', name); - }} - onTaskClick={(task) => { - setSelectedMember(null); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - setSelectedMember(null); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - setEditDialogOpen(false)} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - existingMembers={data.members} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(name, role, workflow) => { - setAddingMemberLoading(true); - void (async () => { - try { - await addMember(teamName, { name, role, workflow }); - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> - - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages will be - preserved, but this name cannot be reused. - - - - - - - - - - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All team - data and tasks will be deleted. - - - - - - - - - - setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} - /> - - { - void (async () => { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - await sendTeamMessage(teamName, { member, text, summary, attachments }); - } catch { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - })(); - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} - /> - - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.add('ring-2', 'ring-blue-400/50'); - setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open ? {} : { initialFilePath: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - projectPath={data.config.projectPath} - onEditorAction={handleEditorAction} - />
{/* Context panel sidebar */} @@ -1876,7 +1886,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele

- {leadSessionLoading ? 'Loading context…' : 'Open the team lead session to view context.'} + {leadSessionLoading + ? 'Loading context…' + : 'Open the team lead session to view context.'}

diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 6949671b..7483c8f2 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -42,14 +42,8 @@ export const ReplyQuoteBlock = ({ {/* Quote text */} -
- +
+
{/* More/less toggle */} diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx index 51e3fd47..f5733612 100644 --- a/src/renderer/components/team/attachments/ImageLightbox.tsx +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -1,11 +1,12 @@ +import 'yet-another-react-lightbox/styles.css'; +import 'yet-another-react-lightbox/plugins/counter.css'; + import { useMemo } from 'react'; import Lightbox from 'yet-another-react-lightbox'; import Counter from 'yet-another-react-lightbox/plugins/counter'; import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen'; import Zoom from 'yet-another-react-lightbox/plugins/zoom'; -import 'yet-another-react-lightbox/styles.css'; -import 'yet-another-react-lightbox/plugins/counter.css'; import type { Plugin, Slide } from 'yet-another-react-lightbox'; diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 14a51489..a87dd6b1 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; +import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -12,7 +13,6 @@ import { import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 6f0f0fc2..91dcc6e1 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -94,6 +94,7 @@ export const CreateTaskDialog = ({ // Reset form when dialog opens (avoid setState during render) useEffect(() => { if (open && !prevOpenRef.current) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setSubject(defaultSubject); if (defaultChip) { const token = chipToken(defaultChip); @@ -101,6 +102,10 @@ export const CreateTaskDialog = ({ descChipDraft.setChips([defaultChip]); } else if (defaultDescription) { descriptionDraft.setValue(defaultDescription); + descChipDraft.clearChipDraft(); + } else { + descriptionDraft.clearDraft(); + descChipDraft.clearChipDraft(); } setOwner(defaultOwner); setBlockedBy([]); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f87509bc..0b7391d5 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -559,6 +559,7 @@ export const CreateTeamDialog = ({ setTeamName(value); setFieldErrors((prev) => { if (!prev.teamName) return prev; + // eslint-disable-next-line sonarjs/no-unused-vars -- destructured to omit teamName from rest const { teamName: _teamName, ...rest } = prev; if (!rest.members && !rest.cwd && localError === 'Check form fields') { setLocalError(null); @@ -636,7 +637,11 @@ export const CreateTeamDialog = ({ {prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => ( -

+

{warning}

))} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index ef1c24dc..d1a63150 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; -import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -25,6 +25,7 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 3a59306f..39863d44 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -1,11 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; -import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react'; - -import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; +import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react'; import type { TaskAttachmentMeta } from '@shared/types'; @@ -105,7 +104,10 @@ export const TaskAttachments = ({ setError('Attachment file not found'); return; } - const mime = att.mimeType && typeof att.mimeType === 'string' ? att.mimeType : 'application/octet-stream'; + const mime = + att.mimeType && typeof att.mimeType === 'string' + ? att.mimeType + : 'application/octet-stream'; const dataUrl = `data:${mime};base64,${base64}`; const blob = await fetch(dataUrl).then((r) => r.blob()); const url = URL.createObjectURL(blob); @@ -212,8 +214,14 @@ export const TaskAttachments = ({ teamName={teamName} taskId={taskId} isDeleting={deletingId === att.id} - onPreview={() => void handlePreview(att)} - onDelete={() => void handleDelete(att.id, att.mimeType)} + onPreview={() => { + // eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise + void handlePreview(att); + }} + onDelete={() => { + // eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise + void handleDelete(att.id, att.mimeType); + }} onDataLoaded={handleThumbLoaded} /> ))} @@ -327,7 +335,7 @@ const AttachmentThumbnail = ({ return (
{isImageMimeType(attachment.mimeType) ? ( @@ -381,7 +389,7 @@ function fileToBase64(file: File): Promise { reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(reader.error); + reader.onerror = () => reject(reader.error ?? new Error('File read failed')); reader.readAsDataURL(file); }); } diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 5bc12d5e..2c7cca1f 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -125,7 +125,7 @@ export const TaskCommentInput = ({ ? pendingAttachments.map((a) => ({ id: a.id, filename: a.filename, - mimeType: a.mimeType as CommentAttachmentPayload['mimeType'], + mimeType: a.mimeType, base64Data: a.base64Data, })) : undefined; @@ -239,6 +239,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/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 5ecef123..0d83452f 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; -import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -11,8 +11,9 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useStore } from '@renderer/store'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; -import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; +import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; @@ -91,6 +92,7 @@ export const TaskCommentsSection = ({ // Reset local UI state when team/task changes. useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setVisibleCount(INITIAL_VISIBLE_COMMENTS); setReplyTo(null); setPreviewImageUrl(null); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index c3dba7ab..27244c0f 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -6,8 +6,6 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; -import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; -import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Dialog, DialogContent, @@ -16,7 +14,9 @@ import { DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; +import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Input } from '@renderer/components/ui/input'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Textarea } from '@renderer/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { markAsRead } from '@renderer/services/commentReadStorage'; diff --git a/src/renderer/components/team/editor/EditorImagePreview.tsx b/src/renderer/components/team/editor/EditorImagePreview.tsx index 3c051cb6..44c74435 100644 --- a/src/renderer/components/team/editor/EditorImagePreview.tsx +++ b/src/renderer/components/team/editor/EditorImagePreview.tsx @@ -35,6 +35,7 @@ export const EditorImagePreview = ({ // Reset state when filePath changes useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setLoading(true); setError(null); setDataUrl(null); diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index b2a237ae..f8c8082b 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -41,6 +41,7 @@ export const QuickOpenDialog = ({ useEffect(() => { let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setLoading(true); window.electronAPI.editor .listFiles() diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 79a430a4..9f38bb8a 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,7 +1,6 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; @@ -40,7 +39,7 @@ export const MemberCard = ({ onSendMessage, onAssignTask, }: MemberCardProps): React.JSX.Element => { - // TODO: lead context display disabled — usage formula is inaccurate + // NOTE: lead context display disabled — usage formula is inaccurate // const teamName = useStore((s) => s.selectedTeamName); // const leadContext = useStore((s) => // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined @@ -184,7 +183,7 @@ export const MemberCard = ({ />
)} - {/* TODO: lead context bar disabled — usage formula is inaccurate */} + {/* NOTE: lead context bar disabled — usage formula is inaccurate */}
{!isRemoved && (
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 8ac7e03c..24bd84fd 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { Pencil } from 'lucide-react'; @@ -31,7 +30,7 @@ export const MemberDetailHeader = ({ }: MemberDetailHeaderProps): React.JSX.Element => { const [editing, setEditing] = useState(false); - // TODO: lead context display disabled — usage formula is inaccurate + // NOTE: lead context display disabled — usage formula is inaccurate // const teamName = useStore((s) => s.selectedTeamName); // const leadContext = useStore((s) => // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined @@ -102,7 +101,7 @@ export const MemberDetailHeader = ({ > {presenceLabel} - {/* TODO: lead context token display disabled — usage formula is inaccurate */} + {/* NOTE: lead context token display disabled — usage formula is inaccurate */} )}
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index d235fc40..470c5562 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; diff --git a/src/renderer/components/team/members/MemberRoleEditor.tsx b/src/renderer/components/team/members/MemberRoleEditor.tsx index 9aa170d1..2aff2f77 100644 --- a/src/renderer/components/team/members/MemberRoleEditor.tsx +++ b/src/renderer/components/team/members/MemberRoleEditor.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { Button } from '@renderer/components/ui/button'; import { RoleSelect } from '@renderer/components/team/RoleSelect'; +import { Button } from '@renderer/components/ui/button'; import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { Check, Loader2, X } from 'lucide-react'; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 07fda10f..5d918032 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -36,7 +36,7 @@ interface MessageComposerProps { const MAX_MESSAGE_LENGTH = 4000; /** Circular progress indicator for lead context usage. */ -const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { +const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { const size = 26; const stroke = 2.5; const radius = (size - stroke) / 2; @@ -150,7 +150,7 @@ export const MessageComposer = ({ const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; - // TODO: lead context ring disabled — usage formula is inaccurate + // NOTE: lead context ring disabled — usage formula is inaccurate // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; // const leadContext = useStore((s) => // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined @@ -307,6 +307,7 @@ export const MessageComposer = ({
)}
+ {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} {(() => { const query = recipientSearch.toLowerCase().trim(); const filtered = query @@ -428,7 +429,7 @@ export const MessageComposer = ({ disabled={sending} cornerAction={
- {/* TODO: ContextRing disabled — usage formula is inaccurate */} + {/* NOTE: ContextRing disabled — usage formula is inaccurate */}
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */}