diff --git a/docs/research/local-image-storage.md b/docs/research/local-image-storage.md index b7c8e5c3..85997c47 100644 --- a/docs/research/local-image-storage.md +++ b/docs/research/local-image-storage.md @@ -8,7 +8,7 @@ This document evaluates approaches for storing images/attachments locally in our ## Approach 1: Filesystem + SQLite Metadata (Recommended) -**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app://attachments/...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. +**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app-img://...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. ### Pros - Best I/O performance — direct filesystem reads, no serialization overhead. diff --git a/package.json b/package.json index c427b37a..4db5dd7d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-agent-teams-ui", "type": "module", - "version": "0.1.0", + "version": "1.0.0", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", "license": "AGPL-3.0", "author": { @@ -80,6 +80,7 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.1", "@codemirror/language-data": "^6.5.2", + "@codemirror/lint": "^6.9.5", "@codemirror/merge": "^6.12.0", "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", @@ -134,6 +135,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", + "yet-another-react-lightbox": "^3.29.1", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 346399b9..147ae6f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@codemirror/language-data': specifier: ^6.5.2 version: 6.5.2 + '@codemirror/lint': + specifier: ^6.9.5 + version: 6.9.5 '@codemirror/merge': specifier: ^6.12.0 version: 6.12.0 @@ -230,6 +233,9 @@ importers: unified: specifier: ^11.0.5 version: 11.0.5 + yet-another-react-lightbox: + specifier: ^3.29.1 + version: 3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zustand: specifier: ^4.5.0 version: 4.5.7(@types/react@18.3.27)(react@18.3.1) @@ -573,8 +579,8 @@ packages: '@codemirror/legacy-modes@6.5.2': resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} - '@codemirror/lint@6.9.4': - resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} '@codemirror/merge@6.12.0': resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==} @@ -6164,6 +6170,20 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yet-another-react-lightbox@3.29.1: + resolution: {integrity: sha512-0cpa+wlleiy2cWNjS9qrcY0+SgZQH/4PDx2uupLMI9Ofip1f7pCgZ95PlVp/EsFsO4ufwOTea51bkLhcEXJJSg==} + engines: {node: '>=14'} + peerDependencies: + '@types/react': ^16 || ^17 || ^18 || ^19 + '@types/react-dom': ^16 || ^17 || ^18 || ^19 + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6432,7 +6452,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.12.1 - '@codemirror/lint': 6.9.4 + '@codemirror/lint': 6.9.5 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.15 '@lezer/common': 1.5.1 @@ -6592,7 +6612,7 @@ snapshots: dependencies: '@codemirror/language': 6.12.1 - '@codemirror/lint@6.9.4': + '@codemirror/lint@6.9.5': dependencies: '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.15 @@ -13183,6 +13203,14 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yet-another-react-lightbox@3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + yocto-queue@0.1.0: {} zip-stream@4.1.1: diff --git a/src/main/index.ts b/src/main/index.ts index 7adf3dc0..40db5d09 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,6 +34,7 @@ import { getTrafficLightPositionForZoom, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, } from '@shared/constants'; +import { isInboxNoiseMessage, parseInboxJson } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; import { app, BrowserWindow } from 'electron'; import { existsSync } from 'fs'; @@ -132,42 +133,6 @@ async function resolveTeamDisplayName(teamName: string): Promise { return resolved; } -/** - * Inbox message types that are internal coordination noise — not useful as OS notifications. - * Matches renderer-side NOISE_TYPES in agentMessageFormatting.ts. - */ -const INBOX_NOISE_TYPES = new Set([ - 'idle_notification', - 'shutdown_approved', - 'teammate_terminated', - 'shutdown_request', -]); - -/** - * Parses an inbox message text that may be serialized JSON. - * Returns null if not valid JSON or not an object. - */ -function parseInboxJson(text: string): Record | null { - const trimmed = text.trim(); - if (!trimmed.startsWith('{')) return null; - try { - const parsed = JSON.parse(trimmed) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // not JSON — plain text message - } - return null; -} - -/** Returns true if the inbox message text is a noise type that should not trigger an OS notification. */ -function isInboxNoiseMessage(text: string): boolean { - const parsed = parseInboxJson(text); - if (!parsed) return false; - return typeof parsed.type === 'string' && INBOX_NOISE_TYPES.has(parsed.type); -} - /** * Extracts human-readable summary and body from an inbox message. * Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}). @@ -448,11 +413,22 @@ function wireFileWatcherEvents(context: ServiceContext): void { // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { - // Auto-relay direct messages to live team lead process (no UI dependency). - if (teamProvisioningService.isTeamAlive(teamName)) { - void teamProvisioningService - .relayLeadInboxMessages(teamName) - .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`)); + // Auto-relay ONLY lead-inbox changes into the live lead process. + // (Relaying on *any* inbox change causes the lead to process irrelevant status noise.) + if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) { + const match = /^inboxes\/(.+)\.json$/.exec(detail); + if (match && teamDataService) { + const inboxName = match[1]; + void teamDataService + .getLeadMemberName(teamName) + .then((leadName) => { + if (!leadName || inboxName !== leadName) return; + return teamProvisioningService.relayLeadInboxMessages(teamName); + }) + .catch((e: unknown) => + logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`) + ); + } } // Show native OS notification for new inbox messages (debounced per inbox). @@ -490,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 29206169..edc4da79 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -11,15 +11,18 @@ import { TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, + TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, + TEAM_GET_TASK_ATTACHMENT, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -37,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, @@ -50,11 +54,9 @@ 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'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; @@ -89,7 +91,6 @@ import type { } from '../services'; import type { AttachmentFileData, - AttachmentMediaType, AttachmentMeta, AttachmentPayload, CreateTaskRequest, @@ -103,6 +104,8 @@ import type { SendMessageResult, TaskAttachmentMeta, TaskComment, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, @@ -195,6 +198,7 @@ export function initializeTeamHandlers( export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); @@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_LAUNCH); @@ -387,12 +392,6 @@ async function handleGetData( const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); - if (isAlive) { - void provisioning - .relayLeadInboxMessages(tn) - .catch((e: unknown) => logger.warn(`Relay failed for ${tn}: ${e}`)); - } - const displayName = data.config.name || tn; const projectPath = data.config.projectPath; @@ -634,6 +633,39 @@ async function validateProvisioningRequest( }; } +async function handleGetClaudeLogs( + _event: IpcMainInvokeEvent, + teamName: unknown, + query?: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + + let parsed: TeamClaudeLogsQuery | undefined; + if (query !== undefined) { + if (!query || typeof query !== 'object') { + return { success: false, error: 'query must be an object' }; + } + const q = query as Record; + parsed = { + offset: typeof q.offset === 'number' ? q.offset : undefined, + limit: typeof q.limit === 'number' ? q.limit : undefined, + }; + } + + return wrapTeamHandler('getClaudeLogs', async () => { + const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed); + return { + lines: data.lines, + total: data.total, + hasMore: data.hasMore, + updatedAt: data.updatedAt, + }; + }); +} + async function handleCreateTeam( event: IpcMainInvokeEvent, request: unknown @@ -937,6 +969,13 @@ async function handleSendMessage( } if (stdinSent) { + const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + })); + // Persistence is best-effort — stdin already delivered the message let result: SendMessageResult; try { @@ -944,20 +983,14 @@ async function handleSendMessage( tn, leadName, payload.text!, - payload.summary + payload.summary, + attachmentMeta ); } catch (persistError) { logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` }; } - const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ - id: a.id, - filename: a.filename, - mimeType: a.mimeType, - size: a.size, - })); - // Save attachment binary data to disk (best-effort) if (validatedAttachments?.length && result.messageId) { void attachmentStore @@ -982,18 +1015,42 @@ async function handleSendMessage( } // Inbox path: offline lead or regular members (no attachment support) + const baseText = payload.text!.trim(); + const memberDeliveryText = isLeadRecipient + ? baseText + : [ + baseText, + '', + AGENT_BLOCK_OPEN, + 'You received a direct message from the human user via the UI.', + 'Please reply back to recipient "user" with a short, human-readable answer.', + 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', + AGENT_BLOCK_CLOSE, + ].join('\n'); const result = await getTeamDataService().sendMessage(tn, { member: memberName, - text: payload.text!, + text: memberDeliveryText, summary: payload.summary, from: payload.from, }); + // Best-effort: if team is alive and recipient is a teammate (not lead), + // also forward via the live lead process so in-process teammates receive it. + if (!isLeadRecipient && isAlive) { + try { + await provisioning.forwardUserDmToTeammate(tn, memberName, baseText, payload.summary); + } catch (e: unknown) { + logger.warn(`Failed to forward user DM to teammate "${memberName}" via lead: ${String(e)}`); + } + } + // Best-effort relay for lead via inbox 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; @@ -1982,7 +2039,7 @@ async function handleAddTaskComment( vTask.value!, safeId, a.filename, - a.mimeType as AttachmentMediaType, + a.mimeType, a.base64Data ); savedAttachments.push(meta); @@ -2104,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); @@ -2137,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) ); } @@ -2169,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/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index ec018951..9fb74ee4 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -41,6 +41,7 @@ export class UpdaterService { await autoUpdater.checkForUpdates(); } catch (error) { logger.error('Check for updates failed:', getErrorMessage(error)); + this.sendStatus({ type: 'error', error: getErrorMessage(error) }); } } @@ -52,6 +53,7 @@ export class UpdaterService { await autoUpdater.downloadUpdate(); } catch (error) { logger.error('Download update failed:', getErrorMessage(error)); + this.sendStatus({ type: 'error', error: getErrorMessage(error) }); } } 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/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 908a2aef..7868cc1c 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -26,6 +26,10 @@ function nowIso() { return new Date().toISOString(); } +function makeId() { + return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random()); +} + function formatError(err) { if (!err) return 'Unknown error'; if (typeof err === 'string') return err; @@ -42,6 +46,29 @@ function die(message, code = 1) { process.exit(code); } +function isSafePathSegment(value) { + const v = String(value == null ? '' : value); + if (v.length === 0 || v.trim().length === 0) return false; + if (v === '.' || v === '..') return false; + if (v.includes('/') || v.includes('\\\\')) return false; + if (v.includes('..')) return false; + if (v.includes('\0')) return false; + return true; +} + +function assertSafePathSegment(label, value) { + const v = String(value == null ? '' : value); + if (!isSafePathSegment(v)) { + die('Invalid ' + String(label)); + } + return v; +} + +function getTaskJsonPath(paths, taskId) { + const id = assertSafePathSegment('taskId', taskId); + return path.join(paths.tasksDir, id + '.json'); +} + function parseArgs(argv) { const out = { _: [], flags: {} }; for (let i = 0; i < argv.length; i++) { @@ -124,7 +151,7 @@ function getTeamName(flags) { (typeof flags.team === 'string' && flags.team.trim()) || (typeof flags['teamName'] === 'string' && flags['teamName'].trim()) || ''; - if (explicit) return explicit; + if (explicit) return assertSafePathSegment('team', explicit); const inferred = inferTeamNameFromScriptPath(); if (inferred) return inferred; die('Missing --team (and could not infer team name from script path)'); @@ -175,6 +202,235 @@ function atomicWrite(filePath, data) { } } +// --------------------------------------------------------------------------- +// Attachments (task + comment) +// --------------------------------------------------------------------------- + +const TASK_ATTACHMENTS_DIR = 'task-attachments'; +const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB + +function sanitizeFilename(original) { + const raw = String(original == null ? '' : original).trim(); + const parts = raw.split(/[\\/]/); + const base = (parts.length ? parts[parts.length - 1] : raw).trim(); + 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; +} + +function readFileHeader(filePath, maxBytes) { + const fd = fs.openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(maxBytes); + const bytes = fs.readSync(fd, buf, 0, maxBytes, 0); + return buf.slice(0, bytes); + } finally { + try { fs.closeSync(fd); } catch { /* ignore */ } + } +} + +function startsWithBytes(buf, bytes) { + if (!buf || buf.length < bytes.length) return false; + for (let i = 0; i < bytes.length; i++) { + if (buf[i] !== bytes[i]) return false; + } + return true; +} + +function detectMimeTypeFromPathAndHeader(filePath, filename) { + const name = String(filename || '').toLowerCase(); + const ext = path.extname(name); + + // Fast path by extension for common types. + if (ext === '.png') return 'image/png'; + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.gif') return 'image/gif'; + if (ext === '.webp') return 'image/webp'; + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.txt') return 'text/plain'; + if (ext === '.md') return 'text/markdown'; + if (ext === '.json') return 'application/json'; + if (ext === '.zip') return 'application/zip'; + + // Sniff magic bytes for a few important formats. + let header; + try { + header = readFileHeader(filePath, 16); + } catch { + return 'application/octet-stream'; + } + if (startsWithBytes(header, [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])) return 'image/png'; // PNG + if (startsWithBytes(header, [0xff,0xd8,0xff])) return 'image/jpeg'; // JPEG + if (header.length >= 6) { + const sig6 = header.slice(0, 6).toString('ascii'); + if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return 'image/gif'; + } + if (header.length >= 12) { + const riff = header.slice(0, 4).toString('ascii'); + const webp = header.slice(8, 12).toString('ascii'); + if (riff === 'RIFF' && webp === 'WEBP') return 'image/webp'; + } + if (header.length >= 5 && header.slice(0, 5).toString('ascii') === '%PDF-') return 'application/pdf'; + if (startsWithBytes(header, [0x50,0x4b,0x03,0x04])) return 'application/zip'; + + return 'application/octet-stream'; +} + +function getTaskAttachmentsDir(paths, taskId) { + const id = assertSafePathSegment('taskId', taskId); + return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, id); +} + +function getStoredAttachmentPath(paths, taskId, attachmentId, filename) { + const safeName = sanitizeFilename(filename); + return path.join(getTaskAttachmentsDir(paths, taskId), String(attachmentId) + '--' + safeName); +} + +function ensureSourceFileReadable(srcPath) { + const st = fs.statSync(srcPath); + if (!st.isFile()) die('Not a file: ' + String(srcPath)); + if (st.size > MAX_TASK_ATTACHMENT_BYTES) { + die( + 'Attachment too large: ' + + (st.size / (1024 * 1024)).toFixed(1) + + ' MB (max ' + + String(MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)) + + ' MB)' + ); + } + return st; +} + +function copyOrLinkFile(srcPath, destPath, mode, allowFallback) { + const m = String(mode || 'copy').toLowerCase(); + if (m === 'link') { + try { + fs.linkSync(srcPath, destPath); + return { mode: 'link', fallbackUsed: false }; + } catch (e) { + if (!allowFallback) throw e; + // Fall back to copy (cross-device link, permissions, etc.) + try { + fs.copyFileSync(srcPath, destPath); + return { mode: 'copy', fallbackUsed: true }; + } catch (e2) { + // Bubble up most useful error + throw e2 || e; + } + } + } + fs.copyFileSync(srcPath, destPath); + return { mode: 'copy', fallbackUsed: false }; +} + +function saveTaskAttachmentFile(paths, taskId, flags) { + const rawFile = (typeof flags.file === 'string' && flags.file.trim()) + ? flags.file.trim() + : (typeof flags.path === 'string' && flags.path.trim()) + ? flags.path.trim() + : ''; + if (!rawFile) die('Missing --file '); + + const srcPath = path.resolve(rawFile); + ensureSourceFileReadable(srcPath); + + const filename = (typeof flags.filename === 'string' && flags.filename.trim()) + ? flags.filename.trim() + : path.basename(srcPath); + const mimeType = (typeof flags['mime-type'] === 'string' && flags['mime-type'].trim()) + ? flags['mime-type'].trim() + : (typeof flags.mimeType === 'string' && flags.mimeType.trim()) + ? flags.mimeType.trim() + : detectMimeTypeFromPathAndHeader(srcPath, filename); + + const attachmentId = makeId(); + const dir = getTaskAttachmentsDir(paths, taskId); + ensureDir(dir); + const destPath = getStoredAttachmentPath(paths, taskId, attachmentId, filename); + const allowFallback = !(flags['no-fallback'] === true); + + if (fs.existsSync(destPath)) die('Attachment destination already exists'); + const result = copyOrLinkFile(srcPath, destPath, flags.mode, allowFallback); + + // Verify write/link + const st = fs.statSync(destPath); + if (!st.isFile() || st.size < 0) die('Attachment write verification failed'); + + const meta = { + id: attachmentId, + filename: filename, + mimeType: mimeType, + size: st.size, + addedAt: nowIso(), + }; + return { meta: meta, storedPath: destPath, storageMode: result.mode, fallbackUsed: result.fallbackUsed }; +} + +function addAttachmentToTask(paths, taskId, meta) { + var lastErr; + for (var attempt = 0; attempt < 8; attempt++) { + try { + const ref = readTask(paths, taskId); + const task = ref.task; + const taskPath = ref.taskPath; + const existing = Array.isArray(task.attachments) ? task.attachments : []; + if (existing.some(function(a) { return a && a.id === meta.id; })) return; + task.attachments = existing.concat([meta]); + writeTask(taskPath, task); + // Verify meta persisted (best-effort) + const verify = readJson(taskPath, null); + if (verify && Array.isArray(verify.attachments) && verify.attachments.some(function(a) { return a && a.id === meta.id; })) { + return; + } + // Verification failed (concurrent overwrite) — retry + } catch (e) { + lastErr = e; + if (attempt === 7) throw e; + } + } + throw lastErr; +} + +function addAttachmentToComment(paths, taskId, commentId, meta) { + var lastErr; + for (var attempt = 0; attempt < 8; attempt++) { + try { + const ref = readTask(paths, taskId); + const task = ref.task; + const taskPath = ref.taskPath; + const comments = Array.isArray(task.comments) ? task.comments : []; + const idx = comments.findIndex(function(c) { return c && String(c.id) === String(commentId); }); + if (idx < 0) die('Comment not found: ' + String(commentId)); + const comment = comments[idx]; + const existing = Array.isArray(comment.attachments) ? comment.attachments : []; + if (!existing.some(function(a) { return a && a.id === meta.id; })) { + comment.attachments = existing.concat([meta]); + } + // Persist update (single atomic write) + task.comments = comments; + writeTask(taskPath, task); + + // Verify + const verify = readJson(taskPath, null); + if (verify && Array.isArray(verify.comments)) { + const vc = verify.comments.find(function(c) { return c && String(c.id) === String(commentId); }); + if (vc && Array.isArray(vc.attachments) && vc.attachments.some(function(a) { return a && a.id === meta.id; })) { + return; + } + } + // Retry on verification failure + } catch (e) { + lastErr = e; + if (attempt === 7) throw e; + } + } + throw lastErr; +} + function normalizeStatus(value) { const v = String(value || '').trim(); if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v; @@ -189,8 +445,9 @@ function normalizeColumn(value) { function getPaths(flags, teamName) { const claudeDir = getClaudeDir(flags); - const teamDir = path.join(claudeDir, 'teams', teamName); - const tasksDir = path.join(claudeDir, 'tasks', teamName); + const safeTeam = assertSafePathSegment('team', teamName); + const teamDir = path.join(claudeDir, 'teams', safeTeam); + const tasksDir = path.join(claudeDir, 'tasks', safeTeam); const kanbanPath = path.join(teamDir, 'kanban-state.json'); const processesPath = path.join(teamDir, 'processes.json'); return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath }; @@ -206,7 +463,7 @@ function inferLeadName(paths) { } function readTask(paths, taskId) { - const taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); + const taskPath = getTaskJsonPath(paths, taskId); const task = readJson(taskPath, null); if (!task) die('Task not found: ' + String(taskId)); return { taskPath, task }; @@ -297,9 +554,7 @@ function addTaskComment(paths, taskId, flags) { } existing = Array.isArray(task.comments) ? task.comments : []; - commentId = crypto.randomUUID - ? crypto.randomUUID() - : String(Date.now()) + '-' + String(Math.random()); + commentId = makeId(); comment = { id: commentId, author: from, @@ -377,13 +632,13 @@ function parseIdList(value) { function taskExists(paths, taskId) { try { - fs.accessSync(path.join(paths.tasksDir, String(taskId) + '.json'), fs.constants.F_OK); + fs.accessSync(getTaskJsonPath(paths, taskId), fs.constants.F_OK); return true; } catch (e) { return false; } } function readTaskObject(paths, taskId) { - var taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); + var taskPath = getTaskJsonPath(paths, taskId); var t = readJson(taskPath, null); if (!t) die('Task not found: #' + taskId); return { task: t, taskPath: taskPath }; @@ -398,7 +653,8 @@ function wouldCreateBlockCycle(paths, sourceId, targetId) { if (visited[current]) continue; visited[current] = true; try { - var t = readJson(path.join(paths.tasksDir, current + '.json'), null); + if (!isSafePathSegment(current)) continue; + var t = readJson(getTaskJsonPath(paths, current), null); if (t && Array.isArray(t.blockedBy)) { for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i])); } @@ -500,7 +756,7 @@ function createTask(paths, flags) { let taskPath; while (true) { nextId = getNextTaskId(paths); - taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); + taskPath = getTaskJsonPath(paths, nextId); var createdAt = nowIso(); task = { id: nextId, @@ -595,12 +851,11 @@ function sendInboxMessage(paths, teamName, flags) { const summary = typeof flags.summary === 'string' ? flags.summary : undefined; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); - const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json'); + const safeTo = assertSafePathSegment('to', to); + const inboxPath = path.join(paths.teamDir, 'inboxes', safeTo + '.json'); ensureDir(path.dirname(inboxPath)); - const messageId = crypto.randomUUID - ? crypto.randomUUID() - : String(Date.now()) + '-' + String(Math.random()); + const messageId = makeId(); const payload = { from, to, @@ -640,9 +895,7 @@ function reviewApprove(paths, teamName, taskId, flags) { // Record review comment in task.comments var existing = Array.isArray(task.comments) ? task.comments : []; - var reviewCommentId = crypto.randomUUID - ? crypto.randomUUID() - : String(Date.now()) + '-' + String(Math.random()); + var reviewCommentId = makeId(); task.comments = existing.concat([{ id: reviewCommentId, author: from, @@ -681,9 +934,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) { // Record review comment in task.comments var existing = Array.isArray(task.comments) ? task.comments : []; - var reviewCommentId = crypto.randomUUID - ? crypto.randomUUID() - : String(Date.now()) + '-' + String(Math.random()); + var reviewCommentId = makeId(); task.comments = existing.concat([{ id: reviewCommentId, author: from, @@ -747,7 +998,7 @@ function processRegister(paths, flags) { const existingIdx = list.findIndex(function (p) { return p.pid === pid; }); const entry = { - id: existingIdx >= 0 ? list[existingIdx].id : (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random())), + id: existingIdx >= 0 ? list[existingIdx].id : makeId(), port: port, url: url, label: label, @@ -802,7 +1053,8 @@ function taskBriefing(paths, teamName, flags) { var allTasks = []; for (var i = 0; i < ids.length; i++) { try { - var taskPath = path.join(paths.tasksDir, ids[i] + '.json'); + if (!isSafePathSegment(ids[i])) continue; + var taskPath = getTaskJsonPath(paths, ids[i]); var t = readJson(taskPath, null); if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) { try { t._mtime = fs.statSync(taskPath).mtime.toISOString(); } catch (_e) { t._mtime = ''; } @@ -936,6 +1188,8 @@ function printHelp() { ' node teamctl.js task unlink --related [--team ]', ' node teamctl.js task set-owner [--notify --from "member"] [--team ]', ' node teamctl.js task comment --text "..." [--from "member"] [--team ]', + ' node teamctl.js task attach --file [--mode copy|link] [--filename ] [--mime-type ] [--no-fallback] [--team ]', + ' node teamctl.js task comment-attach --file [--mode copy|link] [--filename ] [--mime-type ] [--no-fallback] [--team ]', ' node teamctl.js task set-clarification [--from "member"] [--team ]', ' node teamctl.js task briefing --for [--team ]', ' node teamctl.js kanban set-column [--team ]', @@ -951,6 +1205,8 @@ function printHelp() { 'Options:', ' --team Team name (if not under ~/.claude/teams//tools)', ' --claude-dir Override ~/.claude location', + ' --mode For attachments: copy into storage (default) or try hardlink to avoid duplication', + ' --no-fallback For --mode link: fail instead of falling back to copy', '', ].join('\n') ); @@ -1044,7 +1300,7 @@ async function main() { const tasks = []; for (const id of ids) { try { - tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null)); + tasks.push(readJson(getTaskJsonPath(paths, id), null)); } catch {} } process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n'); @@ -1069,6 +1325,42 @@ async function main() { process.stdout.write('OK comment added to task #' + String(id) + '\n'); return; } + if (action === 'attach') { + const id = rest[0] || args.flags.id; + if (!id) die('Usage: task attach --file '); + // Save file to storage first, then update task metadata + const saved = saveTaskAttachmentFile(paths, String(id), args.flags); + try { + addAttachmentToTask(paths, String(id), saved.meta); + } catch (e) { + // Best-effort cleanup of orphaned file on failure + try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ } + throw e; + } + if (saved.fallbackUsed) { + process.stderr.write('WARN: link failed; fell back to copy\n'); + } + process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n'); + return; + } + if (action === 'comment-attach') { + const id = rest[0] || args.flags.id; + const commentId = rest[1] || args.flags['comment-id'] || args.flags.commentId; + if (!id || !commentId) die('Usage: task comment-attach --file '); + const saved = saveTaskAttachmentFile(paths, String(id), args.flags); + try { + addAttachmentToComment(paths, String(id), String(commentId), saved.meta); + } catch (e) { + // Best-effort cleanup of orphaned file on failure + try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ } + throw e; + } + if (saved.fallbackUsed) { + process.stderr.write('WARN: link failed; fell back to copy\n'); + } + process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n'); + return; + } if (action === 'set-clarification') { const id = rest[0] || args.flags.id; const val = rest[1] || args.flags.value; @@ -1252,7 +1544,11 @@ export class TeamAgentToolsInstaller { } if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) { - return toolPath; + // Even when app version is unchanged, the generated script can evolve. + // Keep the installed tool idempotent by content, not only by version. + if (current === desired) { + return toolPath; + } } await atomicWriteAsync(toolPath, desired); diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index cd4d36d7..a6a93eb1 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -10,6 +10,7 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); const ATTACHMENTS_DIR = 'attachments'; +const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap export class TeamAttachmentStore { private assertSafePathSegment(label: string, value: string): void { @@ -58,6 +59,10 @@ export class TeamAttachmentStore { let raw: string; try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile() || stat.size > MAX_ATTACHMENTS_FILE_BYTES) { + return []; + } raw = await fs.promises.readFile(filePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 3f6a830c..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'; @@ -244,6 +245,15 @@ export class TeamConfigReader { } } + // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(memberMap.values()).map((m) => m.name); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const [key, member] of Array.from(memberMap.entries())) { + if (!keepName(member.name)) { + memberMap.delete(key); + } + } + const members = Array.from(memberMap.values()); const summary: TeamSummary = { teamName, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2f9cc319..ec3c8a43 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -12,6 +12,7 @@ import { killProcessByPid } from '@main/utils/processKill'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { getMemberColor } from '@shared/constants/memberColors'; import { createLogger } from '@shared/utils/logger'; +import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -32,6 +33,7 @@ import { TeamTaskWriter } from './TeamTaskWriter'; import type { AddMemberRequest, + AttachmentMeta, CreateTaskRequest, GlobalTask, InboxMessage, @@ -41,6 +43,7 @@ import type { ResolvedTeamMember, SendMessageRequest, SendMessageResult, + TaskAttachmentMeta, TaskComment, TeamConfig, TeamCreateConfigRequest, @@ -256,8 +259,9 @@ export class TeamDataService { } mark('messages'); + let leadTexts: InboxMessage[] = []; try { - const leadTexts = await this.extractLeadSessionTexts(config); + leadTexts = await this.extractLeadSessionTexts(config); if (leadTexts.length > 0) { messages = [...messages, ...leadTexts]; } @@ -266,8 +270,9 @@ export class TeamDataService { } mark('leadTexts'); + let sentMessages: InboxMessage[] = []; try { - const sentMessages = await this.sentMessagesStore.readMessages(teamName); + sentMessages = await this.sentMessagesStore.readMessages(teamName); if (sentMessages.length > 0) { messages = [...messages, ...sentMessages]; } @@ -276,6 +281,33 @@ export class TeamDataService { } mark('sentMessages'); + // Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. + // This avoids double-rendering when we persist lead process messages and later load the lead JSONL. + if (leadTexts.length > 0 && sentMessages.length > 0) { + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const leadSessionFingerprints = new Set(); + for (const msg of leadTexts) { + if (msg.source !== 'lead_session') continue; + leadSessionFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); + } + messages = messages.filter((m) => { + if (m.source !== 'lead_process') return true; + const fp = `${m.from}\0${normalizeText(m.text ?? '')}`; + return !leadSessionFingerprints.has(fp); + }); + } + + // Enrich messages without leadSessionId: assign current session for lead_process/user_sent. + // lead_process messages surviving dedup are from the current session; + // user_sent messages written before this feature lack the field. + if (config.leadSessionId) { + for (const msg of messages) { + if (!msg.leadSessionId && (msg.source === 'lead_process' || msg.source === 'user_sent')) { + msg.leadSessionId = config.leadSessionId; + } + } + } + messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); let metaMembers: TeamConfig['members'] = []; @@ -618,19 +650,31 @@ export class TeamDataService { } async addMember(teamName: string, request: AddMemberRequest): Promise { + const name = request.name.trim(); + if (!name) { + throw new Error('Member name cannot be empty'); + } + const suffixInfo = parseNumericSuffixName(name); + if (suffixInfo && suffixInfo.suffix >= 2) { + throw new Error( + `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.` + ); + } + const members = await this.membersMetaStore.getMembers(teamName); - const existing = members.find((m) => m.name.toLowerCase() === request.name.toLowerCase()); + const existing = members.find((m) => m.name.toLowerCase() === name.toLowerCase()); if (existing) { if (existing.removedAt) { - throw new Error(`Name "${request.name}" was previously used by a removed member`); + throw new Error(`Name "${name}" was previously used by a removed member`); } - throw new Error(`Member "${request.name}" already exists`); + throw new Error(`Member "${name}" already exists`); } const newMember: TeamMember = { - name: request.name, + name, role: request.role?.trim() || undefined, + workflow: request.workflow?.trim() || undefined, agentType: 'general-purpose', color: getMemberColor(members.filter((m) => !m.removedAt).length), joinedAt: Date.now(), @@ -678,6 +722,12 @@ export class TeamDataService { if (name.toLowerCase() === 'team-lead') { throw new Error('Member name "team-lead" is reserved'); } + const suffixInfo = parseNumericSuffixName(name); + if (suffixInfo && suffixInfo.suffix >= 2) { + throw new Error( + `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.` + ); + } nextByName.add(name.toLowerCase()); const prev = existingByName.get(name.toLowerCase()); return { @@ -812,6 +862,7 @@ export class TeamDataService { from: leadName, text: parts.join('\n'), summary: `New task #${task.id} assigned`, + source: 'system_notification', }); } } catch { @@ -856,6 +907,7 @@ export class TeamDataService { from: leadName, text: parts.join('\n'), summary: `Task #${task.id} started`, + source: 'system_notification', }); } } catch { @@ -911,9 +963,10 @@ export class TeamDataService { from: last.actor, text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`, summary: `Task #${task.id} started`, + source: 'system_notification', }); } catch (error) { - logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`); + logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${String(error)}`); } } @@ -944,7 +997,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); } @@ -987,7 +1040,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, @@ -1001,14 +1054,14 @@ export class TeamDataService { ]); const task = tasks.find((t) => t.id === taskId); const leadName = this.resolveLeadNameFromConfig(config); - + const owner = task?.owner?.trim() || null; // Auto-clear needsClarification: "user" on UI comment // UI comments always have author "user" (TeamTaskWriter default) if (task?.needsClarification === 'user') { await this.taskWriter.setNeedsClarification(teamName, taskId, null); } - if (task?.owner && !this.isLeadOwner(task.owner, leadName)) { + if (task && owner && !this.isLeadOwner(owner, leadName)) { // Notify non-lead task owner via inbox (lead → member message) const parts = [ `Comment on task #${taskId} "${task.subject}":\n\n${text}`, @@ -1018,12 +1071,13 @@ export class TeamDataService { AGENT_BLOCK_CLOSE, ]; await this.sendMessage(teamName, { - member: task.owner, + member: owner, from: leadName, text: parts.join('\n'), summary: `Comment on #${taskId}`, + source: 'system_notification', }); - } else if (task?.owner && this.isLeadOwner(task.owner, leadName)) { + } else if (task && owner && this.isLeadOwner(owner, leadName)) { // Notify lead about user's comment on their own task. // Write to lead's inbox — relay delivers to stdin when process is alive. const parts = [ @@ -1038,6 +1092,7 @@ export class TeamDataService { from: 'user', text: parts.join('\n'), summary: `Comment on #${taskId}`, + source: 'system_notification', }); } } catch { @@ -1076,9 +1131,19 @@ export class TeamDataService { teamName: string, leadName: string, text: string, - summary?: string + summary?: string, + attachments?: AttachmentMeta[] ): Promise { const messageId = randomUUID(); + + let leadSessionId: string | undefined; + try { + const config = await this.configReader.getConfig(teamName); + leadSessionId = config?.leadSessionId; + } catch { + // non-critical — proceed without sessionId + } + const msg: InboxMessage = { from: 'user', to: leadName, @@ -1088,6 +1153,8 @@ export class TeamDataService { summary, messageId, source: 'user_sent', + attachments: attachments?.length ? attachments : undefined, + leadSessionId, }; await this.sentMessagesStore.appendMessage(teamName, msg); return { deliveredToInbox: false, deliveredViaStdin: true, messageId }; @@ -1146,6 +1213,7 @@ export class TeamDataService { `node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` + AGENT_BLOCK_CLOSE, summary: `Review request for #${taskId}`, + source: 'system_notification', }); } catch (error) { await this.kanbanManager @@ -1187,7 +1255,19 @@ export class TeamDataService { await this.membersMetaStore.writeMembers( request.teamName, request.members.map((member, index) => ({ - name: member.name, + name: (() => { + const name = member.name.trim(); + if (!name) throw new Error('Member name cannot be empty'); + if (name.toLowerCase() === 'team-lead') + throw new Error('Member name "team-lead" is reserved'); + const suffixInfo = parseNumericSuffixName(name); + if (suffixInfo && suffixInfo.suffix >= 2) { + throw new Error( + `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.` + ); + } + return name; + })(), role: member.role?.trim() || undefined, agentType: 'general-purpose', color: getMemberColor(index), @@ -1217,9 +1297,24 @@ export class TeamDataService { // Dedup broadcasts: same sender + same text → process only once const processedTexts = new Set(); + function isAutomatedCommentNotification(msg: InboxMessage): boolean { + const summary = typeof msg.summary === 'string' ? msg.summary : ''; + if (!/^Comment on #\d+/.test(summary)) return false; + const text = typeof msg.text === 'string' ? msg.text : ''; + if (!text) return false; + // These are system-generated inbox messages that already correspond to a real task comment. + // Syncing them into task.comments causes an immediate "duplicate" (lead echo) in the UI. + if (text.includes('Reply to this comment using:')) return true; + if (text.startsWith('Comment on task #')) return true; + if (text.startsWith('New comment from user on your task #')) return true; + return false; + } + for (const msg of messages) { if (!msg.messageId || !msg.summary || msg.from === 'user') continue; if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; + if (msg.source === 'system_notification') continue; + if (isAutomatedCommentNotification(msg)) continue; const textKey = `${msg.from}\0${msg.text}`; if (processedTexts.has(textKey)) continue; @@ -1325,6 +1420,7 @@ export class TeamDataService { timestamp, read: true, source: 'lead_session', + leadSessionId: config.leadSessionId, }); if (textsReversed.length >= MAX_LEAD_TEXTS) break; } @@ -1347,7 +1443,34 @@ export class TeamDataService { async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise { if (patch.op !== 'request_changes') { + // Keep kanban + task.status consistent: + // - moving a task into kanban review/approved implies the work is complete + // - request_changes already moves it back to in_progress and clears kanban entry + if (patch.op !== 'set_column') { + await this.kanbanManager.updateTask(teamName, taskId, patch); + return; + } + + const previousState = await this.kanbanManager.getState(teamName); + const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId]; + await this.kanbanManager.updateTask(teamName, taskId, patch); + + try { + await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user'); + } catch (error) { + // Best-effort rollback of kanban move if task status update failed. + if (previousKanbanEntry) { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column }) + .catch(() => undefined); + } else { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'remove' }) + .catch(() => undefined); + } + throw error; + } return; } @@ -1374,6 +1497,7 @@ export class TeamDataService { `${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` + `Please fix and mark it as completed when ready.`, summary: `Fix request for #${taskId}`, + source: 'system_notification', }); } catch (error) { await this.taskWriter diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index cfb606be..a0776db4 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -13,6 +13,13 @@ export class TeamInboxWriter { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); const messageId = randomUUID(); + const attachmentMeta = request.attachments?.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + })); + const payload: InboxMessage = { from: request.from ?? 'user', to: request.member, @@ -21,6 +28,8 @@ export class TeamInboxWriter { read: false, summary: request.summary, messageId, + attachments: attachmentMeta?.length ? attachmentMeta : undefined, + ...(request.source && { source: request.source }), }; await withInboxLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 313f3349..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, @@ -78,6 +80,14 @@ export class TeamMemberResolver { // (recipient of SendMessage to "user"). It's not a real AI teammate. names.delete('user'); + // Defense: hide CLI auto-suffixed duplicates (alice-2) when base name (alice) exists. + const keepName = createCliAutoSuffixNameGuard(names); + for (const name of Array.from(names)) { + if (!keepName(name)) { + names.delete(name); + } + } + const members: ResolvedTeamMember[] = []; for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 66ff8f43..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'; @@ -90,6 +91,15 @@ export class TeamMembersMetaStore { deduped.set(normalized.name, normalized); } + // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(deduped.keys()); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const name of allNames) { + if (!keepName(name)) { + deduped.delete(name); + } + } + return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)); } @@ -103,6 +113,15 @@ export class TeamMembersMetaStore { deduped.set(normalized.name, normalized); } + // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(deduped.keys()); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const name of allNames) { + if (!keepName(name)) { + deduped.delete(name); + } + } + const payload: TeamMembersMetaFile = { version: 1, members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a6a44fe8..2376468d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,7 +19,9 @@ import { } from '@shared/constants/agentBlocks'; import { getMemberColor } from '@shared/constants/memberColors'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; +import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; @@ -58,9 +60,8 @@ 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 = 30000; +const PREFLIGHT_TIMEOUT_MS = 60000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const FS_MONITOR_POLL_MS = 2000; @@ -68,8 +69,20 @@ const TASK_WAIT_FALLBACK_MS = 15_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; -const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else'; -const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const; +const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; +const PREFLIGHT_PING_ARGS = [ + '-p', + PREFLIGHT_PING_PROMPT, + '--output-format', + 'text', + '--model', + 'haiku', + '--max-turns', + '1', + '--max-budget-usd', + '0.05', + '--no-session-persistence', +] as const; const PREFLIGHT_EXPECTED = 'PONG'; type TeamsBaseLocation = 'configured' | 'default'; @@ -114,6 +127,16 @@ interface ProvisioningRun { progress: TeamProvisioningProgress; stdoutBuffer: string; stderrBuffer: string; + /** Rolling buffer of CLI log lines (oldest -> newest). */ + claudeLogLines: string[]; + /** Last stream used for claudeLogLines markers. */ + lastClaudeLogStream: 'stdout' | 'stderr' | null; + /** Carry buffer for stdout line splitting (CLI output). */ + stdoutLogLineBuf: string; + /** Carry buffer for stderr line splitting (CLI output). */ + stderrLogLineBuf: string; + /** ISO timestamp when the last CLI line was recorded. */ + claudeLogsUpdatedAt?: string; processKilled: boolean; finalizingByTimeout: boolean; cancelRequested: boolean; @@ -145,6 +168,19 @@ interface ProvisioningRun { * Flushed to liveLeadProcessMessages on result.success. */ directReplyParts: string[]; + /** Monotonic counter for stream-json turns (incremented on result). */ + leadTurnSeq: number; + /** Stable timestamp used for the current aggregated lead turn message. */ + leadTurnMessageTimestamp: string | null; + /** Throttle timestamp for emitting inbox refresh events for lead text. */ + lastLeadTextEmitMs: number; + /** + * When set, the current stdin-injected turn is an internal "forward user DM to teammate" + * request triggered by the UI. We suppress any lead→user echo for that turn. + */ + silentUserDmForward: { target: string; startedAt: string } | null; + /** Safety valve: clears silentUserDmForward if turn never completes. */ + silentUserDmForwardClearHandle: NodeJS.Timeout | null; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ @@ -478,11 +514,20 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Start task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start `, `- Complete task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete `, `- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status `, + `- Add comment: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "..." --from "${leadName}"`, + `- Attach file to task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task attach --file "" [--mode copy|link] [--filename ""] [--mime-type ""]`, + `- Attach file to a specific comment:`, + ` 1) Find commentId: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task get `, + ` 2) Attach: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment-attach --file "" [--mode copy|link] [--filename ""] [--mime-type ""]`, `- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "" --notify --from "${leadName}"`, `- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link --blocked-by `, `- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link --related `, `- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink --blocked-by `, ``, + `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 add --no-fallback.`, + ``, `Dependency guidelines:`, `- Use --blocked-by when a task cannot start until another is done.`, `- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`, @@ -634,8 +679,14 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { ` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` + ` - The tasks will be executed after the team is launched separately.` : `3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. - - Prefer fewer, broader tasks over many micro-tasks. - - Avoid duplicate notifications for the same assignment. + - PRIORITY (delegation-first): your default behavior is to translate user requests into a task plan, create the tasks, and delegate them to teammates. + - Do NOT start executing/implementing tasks yourself in this turn. + - Do NOT “block” on doing the work before creating/assigning tasks — keep this turn fast so the user can send more instructions. + - Exception: only if the team is truly SOLO (no teammates) may you execute tasks yourself. This is NOT the case here. + - Decompose the request into a small set of clear, outcome-based tasks (prefer fewer, broader tasks over many micro-tasks). + - Assign each created task to an appropriate teammate as owner (NOT to yourself), based on role/workflow and current load. + - If ownership is unclear, pick the best default owner and note assumptions in the task description or a task comment. + - Avoid duplicate notifications for the same assignment (one message per member per topic is enough). - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. - If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress. - Review guidance: @@ -688,6 +739,7 @@ Constraints: - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - When messaging "user" (the human): NEVER mention teamctl.js, internal 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 Bash; never ask the user to run a command.${soloConstraint} @@ -719,7 +771,8 @@ ${membersFooter} function buildLaunchPrompt( request: TeamLaunchRequest, members: TeamCreateRequest['members'], - tasks: TeamTask[] + tasks: TeamTask[], + isResume: boolean ): string { const membersBlock = buildMembersPrompt(members); const userPromptBlock = request.prompt?.trim() @@ -828,7 +881,9 @@ ${memberSpawnInstructions} ? `Members:\n${membersBlock}` : 'Members: (none — solo team lead)'; - return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] + const startLabel = isResume ? 'Team Start (resume)' : 'Team Start'; + + return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. You are "${leadName}", the team lead. @@ -847,6 +902,7 @@ Constraints: - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - When messaging "user" (the human): NEVER mention teamctl.js, internal 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 Bash; never ask the user to run a command.${soloConstraint} @@ -965,8 +1021,27 @@ interface CachedProbeResult { } let cachedProbeResult: CachedProbeResult | null = null; +let probeInFlight: Promise<{ + claudePath: string; + authSource: ProvisioningAuthSource; + warning?: string; +} | null> | null = null; + +function isTransientProbeWarning(warning: string): boolean { + const lower = warning.toLowerCase(); + return ( + lower.includes('timeout running:') || + lower.includes('did not complete') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('eai_again') + ); +} export class TeamProvisioningService { + private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; + private readonly runs = new Map(); private readonly activeByTeam = new Map(); private readonly teamOpLocks = new Map>(); @@ -983,6 +1058,89 @@ export class TeamProvisioningService { private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore() ) {} + getClaudeLogs( + teamName: string, + query?: { offset?: number; limit?: number } + ): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + return { lines: [], total: 0, hasMore: false }; + } + const run = this.runs.get(runId); + if (!run) { + return { lines: [], total: 0, hasMore: false }; + } + + const offsetRaw = query?.offset ?? 0; + const limitRaw = query?.limit ?? 100; + const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0; + const limit = Number.isFinite(limitRaw) + ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) + : 100; + + const total = run.claudeLogLines.length; + if (total === 0) { + return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt }; + } + + const newestExclusive = Math.max(0, total - offset); + const oldestInclusive = Math.max(0, newestExclusive - limit); + const normalizeLine = (line: string): string => { + // Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] " + if (line.startsWith('[stdout] ') && line !== '[stdout]') + return line.slice('[stdout] '.length); + if (line.startsWith('[stderr] ') && line !== '[stderr]') + return line.slice('[stderr] '.length); + return line; + }; + + const lines = run.claudeLogLines + .slice(oldestInclusive, newestExclusive) + .map(normalizeLine) + .toReversed(); + return { + lines, + total, + hasMore: oldestInclusive > 0, + updatedAt: run.claudeLogsUpdatedAt, + }; + } + + private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void { + const nowMs = Date.now(); + run.claudeLogsUpdatedAt = new Date(nowMs).toISOString(); + + const marker = stream === 'stdout' ? '[stdout]' : '[stderr]'; + if (run.lastClaudeLogStream !== stream) { + run.lastClaudeLogStream = stream; + run.claudeLogLines.push(marker); + } + + if (stream === 'stdout') { + run.stdoutLogLineBuf += text; + const parts = run.stdoutLogLineBuf.split('\n'); + run.stdoutLogLineBuf = parts.pop() ?? ''; + for (const part of parts) { + const normalized = part.endsWith('\r') ? part.slice(0, -1) : part; + run.claudeLogLines.push(normalized); + } + } else { + run.stderrLogLineBuf += text; + const parts = run.stderrLogLineBuf.split('\n'); + run.stderrLogLineBuf = parts.pop() ?? ''; + for (const part of parts) { + const normalized = part.endsWith('\r') ? part.slice(0, -1) : part; + run.claudeLogLines.push(normalized); + } + } + if (run.claudeLogLines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) { + run.claudeLogLines.splice( + 0, + run.claudeLogLines.length - TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT + ); + } + } + /** * Serializes operations per team name using promise-chaining. * Same pattern as withInboxLock / withTaskLock. @@ -1028,7 +1186,8 @@ export class TeamProvisioningService { const run = this.runs.get(runId); if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null; const { currentTokens, contextWindow } = run.leadContextUsage; - const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percent = Math.max(0, Math.min(100, percentRaw)); return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() }; } @@ -1043,6 +1202,8 @@ export class TeamProvisioningService { } private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; + private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000; + private static readonly LEAD_TEXT_MIN_LENGTH = 30; private emitLeadContextUsage(run: ProvisioningRun): void { if (!run.leadContextUsage || !run.provisioningComplete) return; @@ -1055,7 +1216,8 @@ export class TeamProvisioningService { } run.leadContextUsage.lastEmittedAt = now; const { currentTokens, contextWindow } = run.leadContextUsage; - const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percent = Math.max(0, Math.min(100, percentRaw)); const payload: LeadContextUsage = { currentTokens, contextWindow, @@ -1071,21 +1233,10 @@ export class TeamProvisioningService { async warmup(): Promise { try { - if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) { + if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return; - } - const claudePath = await ClaudeBinaryResolver.resolve(); - if (!claudePath) return; - const { env, authSource } = await this.buildProvisioningEnv(); - const cwd = process.cwd(); - const probe = await this.probeClaudeRuntime(claudePath, cwd, env); - const warning = probe.warning; - if (warning && this.isAuthFailureWarning(warning)) { - // Don't pin auth failures in cache — user may log in after startup. - cachedProbeResult = null; - } else { - cachedProbeResult = { claudePath, authSource, warning, cachedAtMs: Date.now() }; - } + const result = await this.getCachedOrProbeResult(process.cwd()); + if (!result) return; logger.info('CLI warmup completed'); } catch (error) { logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`); @@ -1099,30 +1250,24 @@ export class TeamProvisioningService { await ensureCwdExists(targetCwdForValidation); } - if (cachedProbeResult) { - const ageMs = Date.now() - cachedProbeResult.cachedAtMs; - if (ageMs >= PROBE_CACHE_TTL_MS) { - cachedProbeResult = null; - } else { - const { warning, authSource } = cachedProbeResult; - const warnings: string[] = []; - if (warning) warnings.push(warning); - const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false; - const ready = !warning || authSource !== 'none' || !isAuthFailure; - return { - ready, - message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready', - warnings: warnings.length > 0 ? warnings : undefined, - }; - } + const cached = this.getFreshCachedProbeResult(); + if (cached) { + const { warning, authSource } = cached; + const warnings: string[] = []; + if (warning) warnings.push(warning); + const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false; + const ready = !warning || authSource !== 'none' || !isAuthFailure; + return { + ready, + message: ready + ? warnings.length > 0 + ? 'CLI is ready to launch (see notes)' + : 'CLI is warmed up and ready to launch' + : warning || 'CLI is not ready', + warnings: warnings.length > 0 ? warnings : undefined, + }; } - const claudePath = await ClaudeBinaryResolver.resolve(); - if (!claudePath) { - throw new Error('Claude CLI not found; install it or provide a valid path'); - } - - const { env: executionEnv, authSource } = await this.buildProvisioningEnv(); const targetCwd = cwd?.trim() || process.cwd(); if (!path.isAbsolute(targetCwd)) { throw new Error('cwd must be an absolute path'); @@ -1131,48 +1276,101 @@ export class TeamProvisioningService { const warnings: string[] = []; + const probeResult = await this.getCachedOrProbeResult(targetCwd); + if (!probeResult?.claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); + } + + const { authSource } = probeResult; if (authSource === 'anthropic_api_key') { logger.info('Auth: using explicit ANTHROPIC_API_KEY'); } else if (authSource === 'anthropic_auth_token') { logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY'); } - const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv); - - if (probe.warning) { - const isAuthFailure = this.isAuthFailureWarning(probe.warning); + if (probeResult.warning) { + const isAuthFailure = this.isAuthFailureWarning(probeResult.warning); if (authSource === 'none' && isAuthFailure) { // No auth source + preflight indicates auth failure — block to avoid a confusing hang later. return { ready: false, - message: probe.warning, + message: probeResult.warning, warnings: warnings.length > 0 ? warnings : undefined, }; } // Preflight warnings (including timeouts) should not block provisioning. - warnings.push(probe.warning); - } - - // Cache successful/non-auth-failure results so dialogs don't rerun preflight repeatedly. - // Avoid caching auth failures — user may authenticate externally and retry without app restart. - if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) { - cachedProbeResult = { - claudePath, - authSource, - warning: probe.warning, - cachedAtMs: Date.now(), - }; - } else { - cachedProbeResult = null; + warnings.push(probeResult.warning); } return { ready: true, - message: 'CLI is warmed up and ready to launch', + message: + warnings.length > 0 + ? 'CLI is ready to launch (see notes)' + : 'CLI is warmed up and ready to launch', warnings: warnings.length > 0 ? warnings : undefined, }; } + private getFreshCachedProbeResult(): CachedProbeResult | null { + if (!cachedProbeResult) return null; + const ageMs = Date.now() - cachedProbeResult.cachedAtMs; + if (ageMs >= PROBE_CACHE_TTL_MS) { + cachedProbeResult = null; + return null; + } + return cachedProbeResult; + } + + private async getCachedOrProbeResult( + cwd: string + ): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> { + const cached = this.getFreshCachedProbeResult(); + if (cached) { + return { + claudePath: cached.claudePath, + authSource: cached.authSource, + warning: cached.warning, + }; + } + + if (probeInFlight) { + return await probeInFlight; + } + + probeInFlight = (async () => { + const claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) return null; + + const { env, authSource } = await this.buildProvisioningEnv(); + const probe = await this.probeClaudeRuntime(claudePath, cwd, env); + const result = { + claudePath, + authSource, + ...(probe.warning ? { warning: probe.warning } : {}), + }; + + const shouldCache = + !probe.warning || + (!this.isAuthFailureWarning(probe.warning) && !isTransientProbeWarning(probe.warning)); + + if (shouldCache) { + cachedProbeResult = { ...result, cachedAtMs: Date.now() }; + } else { + // Don't pin auth failures / transient failures in cache — user may fix and retry. + cachedProbeResult = null; + } + + return result; + })(); + + try { + return await probeInFlight; + } finally { + probeInFlight = null; + } + } + private isAuthFailureWarning(text: string): boolean { const lower = text.toLowerCase(); const has401 = /(^|\D)401(\D|$)/.test(lower); @@ -1187,6 +1385,68 @@ export class TeamProvisioningService { ); } + private hasApiError(text: string): boolean { + return /api error:\s*\d{3}\b/i.test(text) || /invalid_request_error/i.test(text); + } + + private sanitizeCliSnippet(text: string): string { + // Remove control characters that often show up as binary noise in CLI error payloads. + // Preserve newlines/tabs for readability. + // 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?.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(); + if (!raw) return null; + // Avoid breaking markdown fences if the payload contains ``` accidentally. + return this.sanitizeCliSnippet(raw).replace(/```/g, '``\\`'); + } + + private failProvisioningWithApiError(run: ProvisioningRun, source: string): void { + if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return; + if (run.progress.state === 'failed' || run.cancelRequested) return; + + const combined = [ + buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer), + run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '', + ] + .filter(Boolean) + .join('\n') + .trim(); + + const snippet = + this.extractApiErrorSnippet(combined) ?? this.extractApiErrorSnippet(source) ?? null; + const status = + /api error:\s*(\d{3})\b/i.exec(combined)?.[1] ?? /api error:\s*(\d{3})\b/i.exec(source)?.[1]; + + const hint = run.isLaunch ? 'Launch' : 'Provisioning'; + const statusLabel = status ? `API Error ${status}` : 'API Error'; + if (snippet) { + run.provisioningOutputParts.push( + `**${hint} failed: ${statusLabel} detected**\n\n\`\`\`\n${snippet}\n\`\`\`` + ); + } else { + run.provisioningOutputParts.push(`**${hint} failed: ${statusLabel} detected**`); + } + + const progress = updateProgress(run, 'failed', `${hint} failed — ${statusLabel}`, { + error: `Claude CLI reported ${statusLabel} during startup. The team was not started.`, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + + run.processKilled = true; + run.cancelRequested = true; + run.child?.stdin?.end(); + killProcessTree(run.child); + this.cleanupRun(run); + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -1248,6 +1508,11 @@ export class TeamProvisioningService { // Reset buffers for fresh attempt run.stdoutBuffer = ''; run.stderrBuffer = ''; + run.claudeLogLines = []; + run.lastClaudeLogStream = null; + run.stdoutLogLineBuf = ''; + run.stderrLogLineBuf = ''; + run.claudeLogsUpdatedAt = undefined; run.authFailureRetried = true; updateProgress(run, 'spawning', 'Auth failed — retrying after short delay'); @@ -1360,6 +1625,7 @@ export class TeamProvisioningService { let stdoutLineBuf = ''; child.stdout.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); + this.appendCliLogs(run, 'stdout', text); run.stdoutBuffer += text; if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) { run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT); @@ -1378,6 +1644,9 @@ export class TeamProvisioningService { } catch { // Not valid JSON — check for auth failure in raw text output this.handleAuthFailureInOutput(run, trimmed, 'stdout'); + if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed)) { + this.failProvisioningWithApiError(run, trimmed); + } } } @@ -1396,6 +1665,7 @@ export class TeamProvisioningService { child.stderr.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); + this.appendCliLogs(run, 'stderr', text); run.stderrBuffer += text; if (run.stderrBuffer.length > STDERR_RING_LIMIT) { run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT); @@ -1403,6 +1673,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)) { + this.failProvisioningWithApiError(run, text); + } const currentTs = Date.now(); if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { @@ -1458,6 +1731,11 @@ export class TeamProvisioningService { startedAt, stdoutBuffer: '', stderrBuffer: '', + claudeLogLines: [], + lastClaudeLogStream: null, + stdoutLogLineBuf: '', + stderrLogLineBuf: '', + claudeLogsUpdatedAt: undefined, processKilled: false, finalizingByTimeout: false, cancelRequested: false, @@ -1475,6 +1753,11 @@ export class TeamProvisioningService { fsPhase: 'waiting_config', leadRelayCapture: null, directReplyParts: [], + leadTurnSeq: 0, + leadTurnMessageTimestamp: null, + lastLeadTextEmitMs: 0, + silentUserDmForward: null, + silentUserDmForwardClearHandle: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -1704,12 +1987,19 @@ export class TeamProvisioningService { // IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json. // Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names. - await this.normalizeTeamConfigForLaunch(request.teamName, configRaw); + try { + await this.normalizeTeamConfigForLaunch(request.teamName, configRaw); + await this.assertConfigLeadOnlyForLaunch(request.teamName); - // Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path - // even if provisioning is interrupted or the user stops the team early. - // If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath). - await this.updateConfigProjectPath(request.teamName, request.cwd); + // Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path + // even if provisioning is interrupted or the user stops the team early. + // If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath). + await this.updateConfigProjectPath(request.teamName, request.cwd); + } catch (error) { + // Restore pre-launch backup so config.json is not left in normalized (lead-only) state. + await this.restorePrelaunchConfig(request.teamName); + throw error; + } let claudePath: string | null; try { @@ -1742,6 +2032,11 @@ export class TeamProvisioningService { startedAt, stdoutBuffer: '', stderrBuffer: '', + claudeLogLines: [], + lastClaudeLogStream: null, + stdoutLogLineBuf: '', + stderrLogLineBuf: '', + claudeLogsUpdatedAt: undefined, processKilled: false, finalizingByTimeout: false, cancelRequested: false, @@ -1759,6 +2054,11 @@ export class TeamProvisioningService { fsPhase: 'waiting_members', leadRelayCapture: null, directReplyParts: [], + leadTurnSeq: 0, + leadTurnMessageTimestamp: null, + lastLeadTextEmitMs: 0, + silentUserDmForward: null, + silentUserDmForwardClearHandle: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -1798,7 +2098,12 @@ export class TeamProvisioningService { ); } - const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks); + const prompt = buildLaunchPrompt( + request, + expectedMemberSpecs, + existingTasks, + Boolean(previousSessionId) + ); let child: ReturnType; const { env: shellEnv } = await this.buildProvisioningEnv(); const launchArgs = [ @@ -1995,6 +2300,66 @@ export class TeamProvisioningService { this.setLeadActivity(run, 'active'); } + /** + * Best-effort: forward a user-written DM to a teammate via the live lead process. + * This covers cases where teammates don't automatically respond to inbox JSON, + * and only react to Claude Code internal SendMessage routing. + * + * Note: We suppress the lead's textual output for this injected turn to avoid + * confusing lead responses like "No action needed." + */ + async forwardUserDmToTeammate( + teamName: string, + teammateName: string, + userText: string, + userSummary?: string + ): Promise { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + throw new Error(`No active process for team "${teamName}"`); + } + const run = this.runs.get(runId); + if (!run?.child?.stdin?.writable) { + throw new Error(`Team "${teamName}" process stdin is not writable`); + } + if (!run.provisioningComplete) { + // Don't inject extra turns during provisioning/bootstrap. + return; + } + + run.silentUserDmForward = { target: teammateName, startedAt: nowIso() }; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } + // Safety valve: if the CLI never emits a result message, don't stay in "silent" mode forever. + run.silentUserDmForwardClearHandle = setTimeout(() => { + run.silentUserDmForward = null; + run.silentUserDmForwardClearHandle = null; + }, 60_000); + run.silentUserDmForwardClearHandle.unref(); + + const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null; + const internal = wrapInAgentBlock( + [ + `UI relay request — forward a direct message to teammate "${teammateName}".`, + `MUST: use the SendMessage tool with recipient="${teammateName}".`, + `MUST: ask the teammate to reply back to recipient "user" (short answer).`, + `CRITICAL: Do NOT send any message to recipient "user" for this turn.`, + ].join('\n') + ); + const message = [ + `User DM relay (internal).`, + internal, + ``, + `Message to forward:`, + ...(summaryLine ? [summaryLine] : []), + userText, + ].join('\n'); + + await this.sendMessageToTeam(teamName, message); + } + /** * Relay unread inbox messages addressed to the team lead into the live lead process. * @@ -2051,8 +2416,23 @@ export class TeamProvisioningService { if (unread.length === 0) return 0; + // Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages. + // These frequently appear when teammates are idle/available and should not prompt + // the lead to respond with "No action needed." + const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text)); + if (noiseUnread.length > 0) { + try { + await this.markInboxMessagesRead(teamName, leadName, noiseUnread); + } catch { + // best-effort + } + } + + const actionableUnread = unread.filter((m) => !isInboxNoiseMessage(m.text)); + if (actionableUnread.length === 0) return 0; + const MAX_RELAY = 10; - const batch = unread.slice(0, MAX_RELAY); + const batch = actionableUnread.slice(0, MAX_RELAY); const message = [ `You have new inbox messages addressed to you (team lead "${leadName}").`, @@ -2180,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', @@ -2401,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?.({ @@ -2419,13 +2799,36 @@ export class TeamProvisioningService { pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { const MAX = 100; const list = this.liveLeadProcessMessages.get(teamName) ?? []; - list.push(message); + const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (id) { + const existingIdx = list.findIndex((m) => (m.messageId ?? '').trim() === id); + if (existingIdx >= 0) { + list[existingIdx] = message; + } else { + list.push(message); + } + } else { + list.push(message); + } if (list.length > MAX) { list.splice(0, list.length - MAX); } this.liveLeadProcessMessages.set(teamName, list); } + private removeLiveLeadProcessMessage(teamName: string, messageId: string): void { + const id = messageId.trim(); + if (!id) return; + const list = this.liveLeadProcessMessages.get(teamName); + if (!list || list.length === 0) return; + const next = list.filter((m) => (m.messageId ?? '').trim() !== id); + if (next.length === 0) { + this.liveLeadProcessMessages.delete(teamName); + } else { + this.liveLeadProcessMessages.set(teamName, next); + } + } + /** * Stop the running process for a team. No-op if team is not running. */ @@ -2470,6 +2873,14 @@ export class TeamProvisioningService { return Array.isArray(inner) ? (inner as Record[]) : null; })(); + const hasSendMessageToUser = (content ?? []).some((part) => { + if (!part || typeof part !== 'object') return false; + if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false; + const input = (part as Record).input; + if (!input || typeof input !== 'object') return false; + return (input as Record).recipient === 'user'; + }); + const textParts = (content ?? []) .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); @@ -2478,6 +2889,10 @@ export class TeamProvisioningService { // Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // rather than stderr or a result.subtype=error. Detect early to avoid false "ready". this.handleAuthFailureInOutput(run, text, 'assistant'); + if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) { + this.failProvisioningWithApiError(run, text); + return; + } logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); // During provisioning (before provisioningComplete), accumulate for live UI preview. // Emission is handled by the throttled emitLogsProgress() in the stdout data handler. @@ -2498,8 +2913,53 @@ export class TeamProvisioningService { }, capture.idleMs); } } else if (run.provisioningComplete) { - // Accumulate assistant text for direct user→lead messages (no relay capture). - run.directReplyParts.push(text); + // Accumulate assistant text for a single "live lead turn" message in Messages. + // If the same assistant message includes SendMessage(to:"user"), prefer the captured + // SendMessage and avoid duplicating it as a separate lead text entry. + if (!run.silentUserDmForward && !hasSendMessageToUser) { + run.directReplyParts.push(text); + const raw = run.directReplyParts.join(''); + const cleanText = stripAgentBlocks(raw).trim(); + if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) { + const leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || + 'team-lead'; + if (!run.leadTurnMessageTimestamp) { + run.leadTurnMessageTimestamp = nowIso(); + } + const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`; + const leadMsg: InboxMessage = { + from: leadName, + text: cleanText, + timestamp: run.leadTurnMessageTimestamp, + read: true, + summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, + messageId, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(run.teamName, leadMsg); + + const now = Date.now(); + if ( + now - run.lastLeadTextEmitMs >= + TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS + ) { + run.lastLeadTextEmitMs = now; + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'lead-text', + }); + } + } + } else if (hasSendMessageToUser) { + run.directReplyParts = []; + run.leadTurnMessageTimestamp = null; + this.removeLiveLeadProcessMessage( + run.teamName, + `lead-turn-${run.runId}-${run.leadTurnSeq}` + ); + } } } @@ -2508,7 +2968,7 @@ export class TeamProvisioningService { // (e.g., after session resume when teamContext is lost). We intercept the tool calls // from stdout and persist them to sentMessages.json under the correct team name, // ensuring the UI and notifications show the right team. - if (run.provisioningComplete) { + if (run.provisioningComplete && !run.silentUserDmForward) { this.captureSendMessageToUser(run, content ?? []); } @@ -2584,11 +3044,18 @@ export class TeamProvisioningService { typeof modelData.contextWindow === 'number' && modelData.contextWindow > 0 ) { - if (run.leadContextUsage) { + if (!run.leadContextUsage) { + run.leadContextUsage = { + currentTokens: 0, + contextWindow: modelData.contextWindow, + lastUsageMessageId: null, + lastEmittedAt: 0, + }; + } else { run.leadContextUsage.contextWindow = modelData.contextWindow; run.leadContextUsage.lastEmittedAt = 0; // force re-emit - this.emitLeadContextUsage(run); } + this.emitLeadContextUsage(run); break; } } @@ -2610,9 +3077,18 @@ export class TeamProvisioningService { ? resultUsage.cache_read_input_tokens : 0; const total = inp + cc + cr; - if (total > 0 && run.leadContextUsage) { - run.leadContextUsage.currentTokens = total; - run.leadContextUsage.lastEmittedAt = 0; + if (total > 0) { + if (!run.leadContextUsage) { + run.leadContextUsage = { + currentTokens: total, + contextWindow: 0, + lastUsageMessageId: null, + lastEmittedAt: 0, + }; + } else { + run.leadContextUsage.currentTokens = total; + run.leadContextUsage.lastEmittedAt = 0; + } this.emitLeadContextUsage(run); } } @@ -2625,62 +3101,53 @@ export class TeamProvisioningService { const combined = capture.textParts.join('').trim(); capture.resolveOnce(combined); } else if (run.provisioningComplete && run.directReplyParts.length > 0) { - // Flush accumulated assistant reply from direct user→lead message + // Finalize the current live lead turn message (single messageId per turn). const rawReply = run.directReplyParts.join('').trim(); run.directReplyParts = []; const leadName = run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; - // Strip agent-only blocks — lead may include coordination content not meant for the user - const replyText = stripAgentBlocks(rawReply); - if (replyText.length > 0) { + const replyText = stripAgentBlocks(rawReply).trim(); + const finalText = + replyText.length > 0 + ? replyText + : rawReply.length > 0 + ? '(Message received and processed)' + : ''; + if (finalText.length > 0) { + if (!run.leadTurnMessageTimestamp) { + run.leadTurnMessageTimestamp = nowIso(); + } + const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`; const replyMsg: InboxMessage = { from: leadName, - to: 'user', - text: replyText, - timestamp: nowIso(), + text: finalText, + timestamp: run.leadTurnMessageTimestamp, read: true, - summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText, - messageId: `lead-direct-${run.runId}-${Date.now()}`, + summary: finalText.length > 60 ? finalText.slice(0, 57) + '...' : finalText, + messageId, source: 'lead_process', }; this.pushLiveLeadProcessMessage(run.teamName, replyMsg); - // Persist to disk so replies survive app restart - void this.sentMessagesStore - .appendMessage(run.teamName, replyMsg) - .catch((e: unknown) => - logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) - ); this.teamChangeEmitter?.({ type: 'inbox', teamName: run.teamName, - detail: 'lead-direct-reply', - }); - } else if (rawReply.length > 0) { - // Lead responded but only with agent-only content — send generic acknowledgment - const fallbackMsg: InboxMessage = { - from: leadName, - to: 'user', - text: '(Message received and processed)', - timestamp: nowIso(), - read: true, - summary: 'Message processed', - messageId: `lead-direct-${run.runId}-${Date.now()}`, - source: 'lead_process', - }; - this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg); - void this.sentMessagesStore - .appendMessage(run.teamName, fallbackMsg) - .catch((e: unknown) => - logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) - ); - this.teamChangeEmitter?.({ - type: 'inbox', - teamName: run.teamName, - detail: 'lead-direct-reply', + detail: 'lead-turn-final', }); } } + // Turn boundary: advance lead turn sequence. + if (run.provisioningComplete) { + run.leadTurnSeq += 1; + run.leadTurnMessageTimestamp = null; + run.directReplyParts = []; + } + // Clear silent relay flag after any successful turn. + run.silentUserDmForward = null; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } if (!run.provisioningComplete && !run.cancelRequested) { void this.handleProvisioningTurnComplete(run); } @@ -2691,6 +3158,18 @@ export class TeamProvisioningService { if (run.leadRelayCapture) { run.leadRelayCapture.rejectOnce(errorMsg); } + // Turn boundary: advance lead turn sequence. + if (run.provisioningComplete) { + run.leadTurnSeq += 1; + run.leadTurnMessageTimestamp = null; + run.directReplyParts = []; + } + // Clear silent relay flag after any errored turn. + run.silentUserDmForward = null; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } if (!run.provisioningComplete && !run.cancelRequested) { const progress = updateProgress( run, @@ -2717,9 +3196,35 @@ export class TeamProvisioningService { // Handle compact_boundary — context was compacted, next assistant message will carry fresh usage if (msg.type === 'system') { const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined; - if (sub === 'compact_boundary' && run.leadContextUsage) { - run.leadContextUsage.lastUsageMessageId = null; - logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`); + if (sub === 'compact_boundary') { + if (run.leadContextUsage) { + run.leadContextUsage.lastUsageMessageId = null; + } + + // Extract compact metadata for the system message + 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)` : ''; + + const compactMsg: InboxMessage = { + from: 'system', + text: `Context compacted${tokenInfo}, trigger: ${trigger}`, + timestamp: nowIso(), + read: true, + summary: `Context compacted (${trigger})`, + messageId: `compact-${run.runId}-${Date.now()}`, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(run.teamName, compactMsg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'compact_boundary', + }); + logger.info( + `[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}` + ); } } } @@ -2732,19 +3237,33 @@ export class TeamProvisioningService { private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { // Guard: must be set synchronously BEFORE any await to prevent // double-invocation from filesystem monitor + stream-json racing. - if (run.provisioningComplete || run.cancelRequested) return; + if ( + run.provisioningComplete || + run.cancelRequested || + run.processKilled || + run.progress.state === 'failed' + ) + return; // Prevent false "ready" when auth failure was printed as assistant text or logs // but the filesystem monitor observed files on disk. - const authFailureText = [ + const preCompleteText = [ buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer), run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '', ] .filter(Boolean) .join('\n') .trim(); - if (authFailureText && this.isAuthFailureWarning(authFailureText)) { - this.handleAuthFailureInOutput(run, authFailureText, 'pre-complete'); + if ( + preCompleteText && + this.hasApiError(preCompleteText) && + !this.isAuthFailureWarning(preCompleteText) + ) { + this.failProvisioningWithApiError(run, preCompleteText); + return; + } + if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) { + this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete'); return; } @@ -2762,6 +3281,10 @@ export class TeamProvisioningService { await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId); await this.cleanupPrelaunchBackup(run.teamName); + // Defense in depth: if the CLI (or a stale config) produced auto-suffixed members (alice-2), + // clean them up so they don't persist and reappear in the UI. + await this.cleanupCliAutoSuffixedMembers(run.teamName); + // Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate // a stale config.json was present during launch (double-launch race). try { @@ -2798,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 @@ -2883,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)}`) ); } @@ -2896,6 +3419,10 @@ export class TeamProvisioningService { clearTimeout(run.timeoutHandle); run.timeoutHandle = null; } + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } this.stopFilesystemMonitor(run); // Remove stream listeners to prevent data handlers firing on a cleaned-up run if (run.child) { @@ -2907,6 +3434,8 @@ export class TeamProvisioningService { this.relayedLeadInboxMessageIds.delete(run.teamName); this.relayedLeadInboxFallbackKeys.delete(run.teamName); this.liveLeadProcessMessages.delete(run.teamName); + // Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines) + this.runs.delete(run.runId); } /** @@ -3516,6 +4045,112 @@ export class TeamProvisioningService { } } + private async cleanupCliAutoSuffixedMembers(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + + const removedFromConfig: string[] = []; + try { + const raw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (raw) { + const parsed = JSON.parse(raw) as Record; + const membersRaw = Array.isArray(parsed.members) + ? (parsed.members as Record[]) + : []; + if (membersRaw.length > 0) { + const teammateNames = membersRaw + .map((m) => (typeof m.name === 'string' ? m.name.trim() : '')) + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ); + + const keepName = createCliAutoSuffixNameGuard(teammateNames); + const nextMembers: Record[] = []; + for (const m of membersRaw) { + const name = typeof m.name === 'string' ? m.name.trim() : ''; + const agentType = typeof m.agentType === 'string' ? m.agentType : ''; + if (!name) continue; + if (agentType === 'team-lead' || name === 'team-lead' || name === 'user') { + nextMembers.push(m); + continue; + } + if (!keepName(name)) { + removedFromConfig.push(name); + continue; + } + nextMembers.push(m); + } + + if (removedFromConfig.length > 0) { + parsed.members = nextMembers; + await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); + logger.warn( + `[${teamName}] Removed CLI auto-suffixed members from config.json: ${removedFromConfig.join(', ')}` + ); + } + } + } + } catch { + // best-effort + } + + let activeNamesForInboxCleanup = new Set(); + try { + const metaMembers = await this.membersMetaStore.getMembers(teamName); + if (metaMembers.length > 0) { + const activeNames = metaMembers + .filter((m) => !m.removedAt) + .map((m) => m.name.trim()) + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ); + + const keepName = createCliAutoSuffixNameGuard(activeNames); + const removedFromMeta: string[] = []; + const nextMeta = metaMembers.filter((m) => { + const name = m.name?.trim() ?? ''; + if (!name) return false; + const lower = name.toLowerCase(); + if (lower === 'team-lead' || lower === 'user' || m.agentType === 'team-lead') return true; + if (!m.removedAt && !keepName(name)) { + removedFromMeta.push(name); + return false; + } + return true; + }); + + if (removedFromMeta.length > 0) { + await this.membersMetaStore.writeMembers(teamName, nextMeta); + logger.warn( + `[${teamName}] Removed CLI auto-suffixed members from members.meta.json: ${removedFromMeta.join(', ')}` + ); + } + + activeNamesForInboxCleanup = new Set( + nextMeta + .filter((m) => !m.removedAt) + .map((m) => m.name.trim()) + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ) + ); + } + } catch { + // best-effort + } + + // Also attempt inbox cleanup (merge alice-2.json into alice.json). + if (activeNamesForInboxCleanup.size > 0) { + try { + await this.mergeAndRemoveDuplicateInboxes(teamName, activeNamesForInboxCleanup); + } catch { + // best-effort + } + } + } + /** * Fallback: scan the project directory for the newest JSONL file * that isn't already in sessionHistory. Returns the session ID or null. @@ -3551,6 +4186,55 @@ export class TeamProvisioningService { } } + private async assertConfigLeadOnlyForLaunch(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const raw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!raw) { + throw new Error('config.json unreadable'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new Error('config.json could not be parsed'); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('config.json has invalid shape'); + } + + const config = parsed as Record; + const members = Array.isArray(config.members) + ? (config.members as Record[]) + : []; + if (members.length === 0) return; + + for (const member of members) { + const name = typeof member.name === 'string' ? member.name.trim() : ''; + if (!name) continue; + const lower = name.toLowerCase(); + const agentType = typeof member.agentType === 'string' ? member.agentType : ''; + + if (agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') continue; + + const leadAgentId = config.leadAgentId; + if ( + typeof leadAgentId === 'string' && + typeof member.agentId === 'string' && + member.agentId === leadAgentId + ) { + continue; + } + + throw new Error( + `Refusing to launch: config.json still contains teammates (e.g. "${name}"), which can trigger CLI auto-suffixes like "${name}-2".` + ); + } + } + private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const backupPath = `${configPath}.prelaunch.bak`; @@ -3905,6 +4589,14 @@ export class TeamProvisioningService { }); } } + // Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(byName.keys()); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const name of allNames) { + if (!keepName(name)) { + byName.delete(name); + } + } const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); if (members.length > 0) { return { members, source: 'members-meta' }; @@ -4004,6 +4696,14 @@ export class TeamProvisioningService { if (!name) continue; byName.set(name, { name }); } + // Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(byName.keys()); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const name of allNames) { + if (!keepName(name)) { + byName.delete(name); + } + } return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); } catch { logger.warn(`[${teamName}] Failed to parse config.json for launch fallback members`); @@ -4050,7 +4750,13 @@ export class TeamProvisioningService { [...PREFLIGHT_PING_ARGS], cwd, env, - PREFLIGHT_TIMEOUT_MS + PREFLIGHT_TIMEOUT_MS, + { + resolveOnOutputMatch: ({ stdout, stderr }) => { + const combined = `${stdout}\n${stderr}`.trim(); + return /\bPONG\b/i.test(combined); + }, + } ); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -4097,7 +4803,7 @@ export class TeamProvisioningService { } const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim(); - const isPong = pongCandidate.toUpperCase() === PREFLIGHT_EXPECTED; + const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate); if (!isPong) { return { warning: @@ -4122,7 +4828,15 @@ export class TeamProvisioningService { args: string[], cwd: string, env: NodeJS.ProcessEnv, - timeoutMs: number + timeoutMs: number, + options?: { + /** + * Optional early success predicate. If this returns true based on + * buffered stdout/stderr, the probe resolves immediately (and the process + * is best-effort terminated) instead of waiting for `close`. + */ + resolveOnOutputMatch?: (ctx: { stdout: string; stderr: string }) => boolean; + } ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawnCli(claudePath, args, { @@ -4130,26 +4844,52 @@ export class TeamProvisioningService { env, stdio: ['ignore', 'pipe', 'pipe'], }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; + let stdoutText = ''; + let stderrText = ''; + let settled = false; const timeoutHandle = setTimeout(() => { + settled = true; killProcessTree(child); reject(new Error(`Timeout running: claude ${args.join(' ')}`)); }, timeoutMs); - child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); - child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + const maybeResolveEarly = (): void => { + if (settled) return; + if (!options?.resolveOnOutputMatch) return; + const ctx = { stdout: stdoutText.trim(), stderr: stderrText.trim() }; + if (!options.resolveOnOutputMatch(ctx)) return; + + settled = true; + clearTimeout(timeoutHandle); + // If the process printed the match but hangs during teardown, don't + // block the UI; terminate best-effort and resolve. + killProcessTree(child); + resolve({ exitCode: 0, stdout: ctx.stdout, stderr: ctx.stderr }); + }; + + child.stdout?.on('data', (chunk: Buffer) => { + stdoutText += chunk.toString('utf8'); + maybeResolveEarly(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderrText += chunk.toString('utf8'); + maybeResolveEarly(); + }); child.once('error', (error) => { + if (settled) return; + settled = true; clearTimeout(timeoutHandle); reject(error); }); child.once('close', (exitCode) => { + if (settled) return; + settled = true; clearTimeout(timeoutHandle); resolve({ exitCode, - stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(), - stderr: Buffer.concat(stderrChunks).toString('utf8').trim(), + stdout: stdoutText.trim(), + stderr: stderrText.trim(), }); }); }); diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 267ca833..88ef5b9e 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -75,6 +75,7 @@ export class TeamSentMessagesStore { color: typeof row.color === 'string' ? row.color : undefined, attachments: Array.isArray(row.attachments) ? row.attachments : undefined, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, + leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined, }); } diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 9661df70..953cc5ba 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -10,7 +10,7 @@ const logger = createLogger('Service:TeamTaskAttachmentStore'); const TASK_ATTACHMENTS_DIR = 'task-attachments'; const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB -const ALLOWED_MIME_TYPES: ReadonlySet = new Set([ +const KNOWN_IMAGE_MIME_TYPES: ReadonlySet = new Set([ 'image/png', 'image/jpeg', 'image/gif', @@ -21,6 +21,9 @@ export class TeamTaskAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( value.length === 0 || + value.trim().length === 0 || + value === '.' || + value === '..' || value.includes('/') || value.includes('\\') || value.includes('..') || @@ -37,14 +40,33 @@ export class TeamTaskAttachmentStore { return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId); } - /** Returns the file path for a specific attachment. */ - private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string { - this.assertSafePathSegment('attachmentId', attachmentId); - return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`); + 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'; + // Keep filenames bounded to avoid OS/path length issues. + return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned; } - /** Map MIME type to file extension. */ - private mimeToExt(mimeType: AttachmentMediaType): string { + /** Returns the file path for a stored attachment (new format). */ + private getStoredFilePath( + teamName: string, + taskId: string, + attachmentId: string, + filename: string + ): string { + this.assertSafePathSegment('attachmentId', attachmentId); + const safeName = this.sanitizeStoredFilename(filename); + return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`); + } + + /** Map known MIME types to file extension (legacy storage format). */ + private mimeToExt(mimeType: string): string { switch (mimeType) { case 'image/png': return '.png'; @@ -54,9 +76,54 @@ export class TeamTaskAttachmentStore { return '.gif'; case 'image/webp': return '.webp'; + default: + return '.bin'; } } + private async findAttachmentFilePath( + teamName: string, + taskId: string, + attachmentId: string, + mimeType?: string + ): Promise { + const dir = this.getTaskDir(teamName, taskId); + + // 1) Prefer legacy path for known image types (older storage format). + if (mimeType && KNOWN_IMAGE_MIME_TYPES.has(mimeType)) { + const legacy = path.join(dir, `${attachmentId}${this.mimeToExt(mimeType)}`); + try { + const stat = await fs.promises.stat(legacy); + if (stat.isFile()) return legacy; + } catch { + // ignore + } + } + + // 2) New format: "--" + try { + const entries = await fs.promises.readdir(dir); + const prefix = `${attachmentId}--`; + const matches = entries.filter((e) => e.startsWith(prefix)); + if (matches.length > 0) { + return path.join(dir, matches[0]); + } + + // 3) Fallback: any file starting with "." (covers legacy when mimeType missing/wrong). + const dotPrefix = `${attachmentId}.`; + const dotMatches = entries.filter((e) => e.startsWith(dotPrefix)); + if (dotMatches.length > 0) { + return path.join(dir, dotMatches[0]); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + // Non-directory or other IO errors should surface. + throw error; + } + + return null; + } + /** * Save an attachment to disk. Data is expected as a base64-encoded string. * Returns metadata for the saved attachment. @@ -69,10 +136,6 @@ export class TeamTaskAttachmentStore { mimeType: AttachmentMediaType, base64Data: string ): Promise { - if (!ALLOWED_MIME_TYPES.has(mimeType)) { - throw new Error(`Unsupported MIME type: ${mimeType}`); - } - const trimmed = base64Data.trim(); // Avoid allocating huge Buffers for obviously too-large payloads. // Base64 decoded size is roughly 3/4 of the string length minus padding. @@ -94,8 +157,7 @@ export class TeamTaskAttachmentStore { const dir = this.getTaskDir(teamName, taskId); await fs.promises.mkdir(dir, { recursive: true }); - const ext = this.mimeToExt(mimeType); - const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); + const filePath = this.getStoredFilePath(teamName, taskId, attachmentId, filename); await fs.promises.writeFile(filePath, buffer); const meta: TaskAttachmentMeta = { @@ -119,8 +181,8 @@ export class TeamTaskAttachmentStore { attachmentId: string, mimeType: AttachmentMediaType ): Promise { - const ext = this.mimeToExt(mimeType); - const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); + const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType); + if (!filePath) return null; try { const buffer = await fs.promises.readFile(filePath); @@ -142,8 +204,8 @@ export class TeamTaskAttachmentStore { attachmentId: string, mimeType: AttachmentMediaType ): Promise { - const ext = this.mimeToExt(mimeType); - const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); + const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType); + if (!filePath) return; try { await fs.promises.unlink(filePath); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 7b7246f6..89a1dde6 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -8,7 +8,6 @@ import * as path from 'path'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import type { - AttachmentMediaType, StatusTransition, TaskAttachmentMeta, TaskComment, @@ -20,12 +19,18 @@ import type { const logger = createLogger('Service:TeamTaskReader'); const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; -const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet = new Set([ - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', -]); +function isValidMimeTypeString(value: unknown): value is string { + if (typeof value !== 'string') return false; + const v = value.trim(); + if (!v) return false; + // Keep it reasonably bounded and avoid control characters. + if (v.length > 200) return false; + if (v.includes('\0') || /[\r\n]/.test(v)) return false; + // Minimal MIME shape: type/subtype + const slash = v.indexOf('/'); + if (slash <= 0 || slash === v.length - 1) return false; + return true; +} export class TeamTaskReader { /** @@ -172,8 +177,12 @@ export class TeamTaskReader { : 'pending', workIntervals, statusHistory, - blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, - blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, + blocks: Array.isArray(parsed.blocks) + ? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, + blockedBy: Array.isArray(parsed.blockedBy) + ? (parsed.blockedBy as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, related: Array.isArray(parsed.related) ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') : undefined, @@ -197,19 +206,32 @@ export class TeamTaskReader { ? c.type : ('regular' as const), attachments: Array.isArray(c.attachments) - ? (c.attachments as unknown[]).filter( - (a): a is TaskAttachmentMeta => - Boolean(a) && - typeof a === 'object' && - typeof (a as Record).id === 'string' && - typeof (a as Record).filename === 'string' && - typeof (a as Record).mimeType === 'string' && - VALID_ATTACHMENT_MIME_TYPES.has( - (a as Record).mimeType as string - ) && - typeof (a as Record).size === 'number' && - typeof (a as Record).addedAt === 'string' - ) + ? (() => { + const filtered = (c.attachments as unknown[]) + .filter((a): a is TaskAttachmentMeta => { + if (!a || typeof a !== 'object') return false; + const row = a as Record; + const size = row.size; + return ( + typeof row.id === 'string' && + typeof row.filename === 'string' && + typeof row.mimeType === 'string' && + isValidMimeTypeString(row.mimeType) && + typeof size === 'number' && + Number.isFinite(size) && + size >= 0 && + typeof row.addedAt === 'string' + ); + }) + .map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: String(a.mimeType).trim(), + size: a.size, + addedAt: a.addedAt, + })); + return filtered.length > 0 ? filtered : undefined; + })() : undefined, })) : undefined, @@ -221,23 +243,25 @@ export class TeamTaskReader { deletedAt: undefined, // deleted tasks are filtered out below attachments: Array.isArray(parsed.attachments) ? (parsed.attachments as unknown[]) - .filter( - (a): a is TaskAttachmentMeta => - Boolean(a) && - typeof a === 'object' && - typeof (a as Record).id === 'string' && - typeof (a as Record).filename === 'string' && - typeof (a as Record).mimeType === 'string' && - VALID_ATTACHMENT_MIME_TYPES.has( - (a as Record).mimeType as string - ) && - typeof (a as Record).size === 'number' && - typeof (a as Record).addedAt === 'string' - ) + .filter((a): a is TaskAttachmentMeta => { + if (!a || typeof a !== 'object') return false; + const row = a as Record; + const size = row.size; + return ( + typeof row.id === 'string' && + typeof row.filename === 'string' && + typeof row.mimeType === 'string' && + isValidMimeTypeString(row.mimeType) && + typeof size === 'number' && + Number.isFinite(size) && + size >= 0 && + typeof row.addedAt === 'string' + ); + }) .map((a) => ({ id: a.id, filename: a.filename, - mimeType: a.mimeType, + mimeType: String(a.mimeType).trim(), size: a.size, addedAt: a.addedAt, })) @@ -256,6 +280,17 @@ export class TeamTaskReader { } } + // Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11). + // Fall back to stable lexicographic ordering for unexpected non-numeric IDs. + tasks.sort((a, b) => { + const aIsNumeric = /^\d+$/.test(a.id); + const bIsNumeric = /^\d+$/.test(b.id); + if (aIsNumeric && bIsNumeric) return Number(a.id) - Number(b.id); + if (aIsNumeric) return -1; + if (bIsNumeric) return 1; + return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' }); + }); + return tasks; } diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index d1d6603e..207c6cff 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -319,6 +319,9 @@ export class TeamTaskWriter { const task = JSON.parse(raw) as TeamTask; const prevStatus = task.status; + if (prevStatus === status) { + return; + } const nowIso = new Date().toISOString(); // Maintain workIntervals as periods of time where status === 'in_progress'. diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index ee21dc5d..117ad118 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -250,6 +250,25 @@ function mergeMember( }); } +function dropCliAutoSuffixedMembers( + memberMap: Map +): void { + const keys = Array.from(memberMap.keys()); + const allLower = new Set(keys); // keys are already lowercased + for (const key of keys) { + const member = memberMap.get(key); + const name = member?.name ?? ''; + const match = /^(.+)-(\d+)$/.exec(name.trim()); + if (!match?.[1] || !match[2]) continue; + const suffix = Number(match[2]); + if (!Number.isFinite(suffix) || suffix < 2) continue; + const baseLower = match[1].toLowerCase(); + if (allLower.has(baseLower)) { + memberMap.delete(key); + } + } +} + async function listTeams( payload: ListTeamsPayload ): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> { @@ -392,6 +411,8 @@ async function listTeams( } } + dropCliAutoSuffixedMembers(memberMap); + const members = Array.from(memberMap.values()); const summary = { teamName, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a3ca64c3..72722ee0 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list'; /** Get detailed team data */ export const TEAM_GET_DATA = 'team:getData'; +/** Get buffered Claude CLI logs (paged, newest-first) */ +export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; + /** Update team kanban state */ export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 8361d241..687132ad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,15 +67,18 @@ import { TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, + TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, + TEAM_GET_TASK_ATTACHMENT, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -90,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, @@ -167,6 +168,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CommentAttachmentPayload, ConflictCheckResult, ContextInfo, CreateTaskRequest, @@ -192,11 +194,12 @@ import type { SshConnectionConfig, SshConnectionStatus, SshLastConnection, - CommentAttachmentPayload, TaskAttachmentMeta, TaskChangeSetV2, TaskComment, TeamChangeEvent, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, @@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = { getData: async (teamName: string) => { return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, + getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { + return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); + }, deleteTeam: async (teamName: string) => { return invokeIpcWithResult(TEAM_DELETE_TEAM, teamName); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b2c69fa8..7fc64c7c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -45,6 +45,8 @@ import type { SshLastConnection, SubagentDetail, TeamChangeEvent, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, TeamData, @@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI { getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, + getClaudeLogs: async ( + _teamName: string, + _query?: TeamClaudeLogsQuery + ): Promise => { + console.warn('[HttpAPIClient] getClaudeLogs is not available in browser mode'); + return { lines: [], total: 0, hasMore: false }; + }, deleteTeam: async (_teamName: string): Promise => { throw new Error('Team deletion is not available in browser mode'); }, diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index 1fd2db8e..d6f90ad3 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -29,6 +29,10 @@ interface DisplayItemListProps { onItemClick: (itemId: string) => void; expandedItemIds: Set; aiGroupId: string; + /** Render order for display items (visual only). */ + order?: 'chronological' | 'newest-first'; + /** Optional local search query override for markdown highlighting */ + searchQueryOverride?: string; /** Tool use ID to highlight for error deep linking */ highlightToolUseId?: string; /** Custom highlight color from trigger */ @@ -66,6 +70,8 @@ export const DisplayItemList = ({ onItemClick, expandedItemIds, aiGroupId, + order = 'chronological', + searchQueryOverride, highlightToolUseId, highlightColor, notificationColorMap, @@ -96,7 +102,13 @@ export const DisplayItemList = ({ } return ( -
+
{items.map((item, index) => { let itemKey = ''; let element: React.ReactNode = null; @@ -120,6 +132,8 @@ export const DisplayItemList = ({ preview={truncateText(item.content, 150)} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} + searchQueryOverride={searchQueryOverride} /> ); break; @@ -143,6 +157,8 @@ export const DisplayItemList = ({ preview={truncateText(item.content, 150)} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} + searchQueryOverride={searchQueryOverride} /> ); break; @@ -155,6 +171,7 @@ export const DisplayItemList = ({ linkedTool={item.tool} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + searchQueryOverride={searchQueryOverride} isHighlighted={highlightToolUseId === item.tool.id} highlightColor={highlightColor} notificationDotColor={notificationColorMap?.get(item.tool.id)} @@ -235,7 +252,12 @@ export const DisplayItemList = ({ onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} > - + ); break; diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index 0879452a..352cde15 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -12,6 +12,7 @@ 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'; @@ -110,7 +111,7 @@ export const SessionContextHeader = ({ )}
{/* Percentage of total */} - {totalSessionTokens !== undefined && totalSessionTokens > 0 && ( + {formatPercentOfTotal(totalTokens, totalSessionTokens) && ( - {Math.min((totalTokens / totalSessionTokens) * 100, 100).toFixed(1)}% of total + {formatPercentOfTotal(totalTokens, totalSessionTokens)} )}
diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 83b7ac8f..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'; @@ -132,10 +133,7 @@ export const SessionContextPanel = ({ }, [injections]); // Calculate total tokens - const totalTokens = useMemo( - () => injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), - [injections] - ); + const totalTokens = useMemo(() => sumContextInjectionTokens(injections), [injections]); // Section token counts const claudeMdTokens = useMemo( diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 66e772f7..e1a20ec0 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -18,7 +18,7 @@ interface BaseItemProps { /** Primary label (e.g., "Thinking", "Output", tool name) */ label: string; /** Summary text shown after the label */ - summary?: string; + summary?: React.ReactNode; /** Token count to display */ tokenCount?: number; /** Label for tokens (default: "tokens") */ diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index c11798d1..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 { @@ -45,6 +47,8 @@ interface LinkedToolItemProps { linkedTool: LinkedToolItemType; onClick: () => void; isExpanded: boolean; + /** Optional local search query override for inline highlighting */ + searchQueryOverride?: string; /** Whether this item should be highlighted for error deep linking */ isHighlighted?: boolean; /** Custom highlight color from trigger */ @@ -59,6 +63,7 @@ export const LinkedToolItem: React.FC = ({ linkedTool, onClick, isExpanded, + searchQueryOverride, isHighlighted, highlightColor, notificationDotColor, @@ -66,6 +71,17 @@ export const LinkedToolItem: React.FC = ({ }) => { const status = getToolStatus(linkedTool); const summary = getToolSummary(linkedTool.name, linkedTool.input); + const summaryNode = + searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText( + summary, + searchQueryOverride, + `${linkedTool.id ?? linkedTool.name}:summary`, + { + forceAllActive: true, + } + ) + : summary; const elementRef = useRef(null); // Combined ref callback - handles both internal ref and external registration @@ -155,7 +171,7 @@ export const LinkedToolItem: React.FC = ({ /> } label={linkedTool.name} - summary={summary} + summary={summaryNode} tokenCount={getToolContextTokens(linkedTool)} status={status} durationMs={linkedTool.durationMs} diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index 63b9d17c..2e0c88dd 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { MessageSquare } from 'lucide-react'; +import { highlightQueryInText } from '../searchHighlightUtils'; import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; @@ -15,6 +16,10 @@ interface TextItemProps { preview: string; onClick: () => void; isExpanded: boolean; + /** Optional local search query for inline highlighting */ + searchQueryOverride?: string; + /** Optional stable item id for search highlighting */ + markdownItemId?: string; /** Additional classes for highlighting (e.g., error deep linking) */ highlightClasses?: string; /** Inline styles for highlighting (used by custom hex colors) */ @@ -28,12 +33,24 @@ export const TextItem: React.FC = ({ preview, onClick, isExpanded, + searchQueryOverride, + markdownItemId, highlightClasses, highlightStyle, notificationDotColor, }) => { const fullContent = step.content.outputText ?? preview; const truncatedPreview = truncateText(preview, 60); + const summary = searchQueryOverride + ? highlightQueryInText( + truncatedPreview, + searchQueryOverride, + `${markdownItemId ?? step.id}:summary`, + { + forceAllActive: true, + } + ) + : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; @@ -42,7 +59,7 @@ export const TextItem: React.FC = ({ } label="Output" - summary={truncatedPreview} + summary={summary} tokenCount={tokenCount} onClick={onClick} isExpanded={isExpanded} @@ -50,7 +67,13 @@ export const TextItem: React.FC = ({ highlightStyle={highlightStyle} notificationDotColor={notificationDotColor} > - + ); }; diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index e1034ef4..c74742ee 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Brain } from 'lucide-react'; +import { highlightQueryInText } from '../searchHighlightUtils'; import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; @@ -15,6 +16,10 @@ interface ThinkingItemProps { preview: string; onClick: () => void; isExpanded: boolean; + /** Optional local search query for inline highlighting */ + searchQueryOverride?: string; + /** Optional stable item id for search highlighting */ + markdownItemId?: string; /** Additional classes for highlighting (e.g., error deep linking) */ highlightClasses?: string; /** Inline styles for highlighting (used by custom hex colors) */ @@ -28,12 +33,24 @@ export const ThinkingItem: React.FC = ({ preview, onClick, isExpanded, + searchQueryOverride, + markdownItemId, highlightClasses, highlightStyle, notificationDotColor, }) => { const fullContent = step.content.thinkingText ?? preview; const truncatedPreview = truncateText(preview, 60); + const summary = searchQueryOverride + ? highlightQueryInText( + truncatedPreview, + searchQueryOverride, + `${markdownItemId ?? step.id}:summary`, + { + forceAllActive: true, + } + ) + : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; @@ -42,7 +59,7 @@ export const ThinkingItem: React.FC = ({ } label="Thinking" - summary={truncatedPreview} + summary={summary} tokenCount={tokenCount} onClick={onClick} isExpanded={isExpanded} @@ -50,7 +67,13 @@ export const ThinkingItem: React.FC = ({ highlightStyle={highlightStyle} notificationDotColor={notificationDotColor} > - + ); }; diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 873351b4..34684f96 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -35,6 +35,8 @@ export interface SearchContext { matchCounter: { current: number }; isCurrentItem: boolean; currentMatchIndexInItem: number | null; + /** When true, render all matches using the "current" highlight style */ + forceAllActive?: boolean; } /** @@ -79,7 +81,8 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode } const isCurrentResult = - ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current; + ctx.forceAllActive === true || + (ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current); parts.push( React.createElement( @@ -109,6 +112,19 @@ 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, + itemId: string, + options?: { forceAllActive?: boolean } +): React.ReactNode { + const ctx = createSearchContext(query, itemId, [], -1); + if (!ctx) return text; + if (options?.forceAllActive) ctx.forceAllActive = true; + return highlightSearchInChildren(text, ctx); +} + /** * Recursively process React children to highlight search terms in text nodes. * Preserves the React element tree structure (markdown components, etc.) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index f7670baf..460640b0 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import ReactMarkdown, { type Components } from 'react-markdown'; +import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'; import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { CODE_BG, CODE_BORDER, @@ -23,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'; @@ -48,6 +49,8 @@ interface MarkdownViewerProps { label?: string; // Optional label like "Thinking", "Output", etc. /** When provided, enables search term highlighting within the markdown */ itemId?: string; + /** Optional override for search highlighting (local search, e.g. Claude logs) */ + searchQueryOverride?: string; /** When true, shows a copy button (overlay when no label, inline in header when label exists) */ copyable?: boolean; /** When true, renders without wrapper background/border (for embedding inside cards) */ @@ -60,6 +63,15 @@ interface MarkdownViewerProps { // Helpers // ============================================================================= +/** + * Custom URL transform that preserves task:// and mention:// protocols. + * react-markdown v10 strips non-standard protocols by default. + */ +function allowCustomProtocols(url: string): string { + if (url.startsWith('task://') || url.startsWith('mention://')) return url; + return defaultUrlTransform(url); +} + /** Check if a URL is relative (not absolute, not data, not mailto, not hash) */ function isRelativeUrl(url: string): boolean { return ( @@ -200,13 +212,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ), // Links — inline element, no hl(); parent block element's hl() descends here - // task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem) + // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture // mention:// links render as colored inline badges a: ({ href, children }) => { if (href?.startsWith('mention://')) { const path = href.slice('mention://'.length); const slashIdx = path.indexOf('/'); - const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; + let color = ''; + try { + color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; + } catch { + // malformed percent-encoding — use empty color + } const colorSet = getTeamColorSet(color); const bg = colorSet.badge; return ( @@ -223,6 +240,21 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ); } + if (href?.startsWith('task://')) { + const taskId = href.slice('task://'.length); + return ( + + e.preventDefault()} + > + {children} + + + ); + } return ( { e.preventDefault(); - if (href && !href.startsWith('task://')) { + if (href) { void api.openExternal(href); } }} @@ -418,6 +450,7 @@ export const MarkdownViewer: React.FC = ({ className = '', label, itemId, + searchQueryOverride, copyable = false, bare = false, baseDir, @@ -560,10 +593,17 @@ export const MarkdownViewer: React.FC = ({ } // Create search context (fresh each render so counter starts at 0) + const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); + const effectiveMatches = searchQueryOverride ? [] : searchMatches; + const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; const searchCtx = - searchQuery && itemId - ? createSearchContext(searchQuery, itemId, searchMatches, currentSearchIndex) + effectiveQuery && itemId + ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) : null; + // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). + if (searchCtx && searchQueryOverride) { + searchCtx.forceAllActive = true; + } // Create markdown components with optional search highlighting // When search is active, create fresh each render (match counter is stateful and must start at 0) @@ -629,6 +669,7 @@ export const MarkdownViewer: React.FC = ({ remarkPlugins={[remarkGfm]} rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS} components={components} + urlTransform={allowCustomProtocols} > {content} diff --git a/src/renderer/components/common/WarningBanner.tsx b/src/renderer/components/common/WarningBanner.tsx new file mode 100644 index 00000000..5bc540ac --- /dev/null +++ b/src/renderer/components/common/WarningBanner.tsx @@ -0,0 +1,25 @@ +import { AlertTriangle } from 'lucide-react'; + +interface WarningBannerProps { + children: React.ReactNode; + className?: string; + icon?: React.ReactNode; +} + +export const WarningBanner = ({ + children, + className = '', + icon, +}: WarningBannerProps): React.JSX.Element => ( +
+ {icon ?? } +
{children}
+
+); diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 7d12e284..ed036990 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -46,7 +46,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'notifications' && } {tab.type === 'settings' && } {tab.type === 'teams' && } - {tab.type === 'team' && } + {tab.type === 'team' && ( + + + + )} {tab.type === 'session' && ( diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx index b9629c89..4b631c7f 100644 --- a/src/renderer/components/layout/SidebarHeader.tsx +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -44,9 +44,11 @@ export const SidebarHeader = (): React.JSX.Element => { } as React.CSSProperties } > -
- -
+ {isMacElectron && ( +
+ +
+ )}
-
+ + {tab.fromSearch && ( + + + + )} + {isPinned && ( + + + + )} + {tab.label} {isTeamTab && ( )} + ); }; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 893f27d6..74b5f215 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -302,13 +302,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { scrollContainerRef.current = el; setDroppableRef(el); }} - className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto" + className="scrollbar-none flex min-w-0 flex-1 items-center gap-1" style={ { - maxWidth: '75%', WebkitAppRegion: 'no-drag', outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outlineOffset: '-1px', + overflowX: 'auto', + overflowY: 'hidden', } as React.CSSProperties } > @@ -351,7 +352,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { Gives users a reliable window-drag target regardless of how many tabs are open. Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
e.stopPropagation()}> +
e.stopPropagation()}>
+ + setConfigEditorOpen(false)} + onConfigSaved={() => { + // Config saved via editor — settings page will pick up changes on next render + }} + /> ); }; diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 2e7647ca..aadc0c9c 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -27,6 +27,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { fetchCliStatus, installCli, isBusy, + cliStatusLoading, } = useCliInstaller(); useEffect(() => { @@ -129,14 +130,24 @@ export const CliStatusSection = (): React.JSX.Element | null => { {cliStatus.installed && !cliStatus.updateAvailable && ( )} diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx new file mode 100644 index 00000000..dabe26d1 --- /dev/null +++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx @@ -0,0 +1,416 @@ +/** + * ConfigEditorDialog — inline JSON config editor powered by CodeMirror. + * + * Opens as a dialog, shows the full app config as formatted JSON. + * Auto-saves on changes with debounce. Shows validation errors for malformed JSON. + */ + +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 { 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'; +import { + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, +} from '@codemirror/view'; +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; +import { AlertTriangle, Check, Loader2, X } from 'lucide-react'; + +import type { AppConfig } from '@renderer/types/data'; + +// ============================================================================= +// Constants +// ============================================================================= + +const SAVE_DEBOUNCE_MS = 800; + +// ============================================================================= +// JSON Linter +// ============================================================================= + +const jsonLinter = linter((view: EditorView) => { + const diagnostics: Diagnostic[] = []; + const text = view.state.doc.toString(); + try { + JSON.parse(text); + } catch (e) { + if (e instanceof SyntaxError) { + const match = /position (\d+)/.exec(e.message); + const pos = match ? parseInt(match[1], 10) : 0; + const safePos = Math.min(pos, text.length); + diagnostics.push({ + from: safePos, + to: Math.min(safePos + 1, text.length), + severity: 'error', + message: e.message, + }); + } + } + return diagnostics; +}); + +// ============================================================================= +// Types +// ============================================================================= + +interface ConfigEditorDialogProps { + open: boolean; + onClose: () => void; + onConfigSaved: (config: AppConfig) => void; +} + +type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +// ============================================================================= +// Component +// ============================================================================= + +export const ConfigEditorDialog = ({ + open, + onClose, + onConfigSaved, +}: ConfigEditorDialogProps): React.JSX.Element | null => { + const editorRef = useRef(null); + const viewRef = useRef(null); + const saveTimerRef = useRef>(); + const savedRevertTimerRef = useRef>(); + const [saveStatus, setSaveStatus] = useState('idle'); + const [jsonError, setJsonError] = useState(null); + const [loading, setLoading] = useState(true); + const initialConfigRef = useRef(''); + + const saveConfig = useCallback( + async (jsonText: string) => { + try { + const parsed = JSON.parse(jsonText) as AppConfig; + setJsonError(null); + setSaveStatus('saving'); + + // Save each section separately via existing API + if (parsed.general) { + await api.config.update('general', parsed.general); + } + if (parsed.notifications) { + await api.config.update('notifications', parsed.notifications); + } + if (parsed.display) { + await api.config.update('display', parsed.display); + } + if (parsed.sessions) { + await api.config.update('sessions', parsed.sessions); + } + + // Re-fetch to get the canonical saved state + const fresh = await api.config.get(); + onConfigSaved(fresh); + useStore.setState({ appConfig: fresh }); + initialConfigRef.current = JSON.stringify(fresh, null, 2); + + setSaveStatus('saved'); + if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); + savedRevertTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); + } catch (e) { + if (e instanceof SyntaxError) { + setJsonError(e.message); + setSaveStatus('idle'); + } else { + setSaveStatus('error'); + setJsonError(e instanceof Error ? e.message : 'Failed to save config'); + if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); + savedRevertTimerRef.current = setTimeout(() => { + setSaveStatus('idle'); + setJsonError(null); + }, 4000); + } + } + }, + [onConfigSaved] + ); + + const scheduleSave = useCallback( + (jsonText: string) => { + // Validate JSON before scheduling save + try { + JSON.parse(jsonText); + setJsonError(null); + } catch (e) { + if (e instanceof SyntaxError) { + setJsonError(e.message); + } + return; + } + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + void saveConfig(jsonText); + }, SAVE_DEBOUNCE_MS); + }, + [saveConfig] + ); + + // Initialize CodeMirror when dialog opens + useEffect(() => { + 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 => { + try { + const config = await api.config.get(); + if (destroyed) return; + + const jsonText = JSON.stringify(config, null, 2); + initialConfigRef.current = jsonText; + setLoading(false); + + // Wait for DOM render + requestAnimationFrame(() => { + if (destroyed || !editorRef.current) return; + + // 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, + // 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; + }); + } catch (e) { + if (destroyed) return; + setLoading(false); + setJsonError(e instanceof Error ? e.message : 'Failed to load config'); + } + }; + + void init(); + + return () => { + destroyed = true; + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); + }; + }, [open, scheduleSave]); + + // Escape key handler + useEffect(() => { + if (!open) return; + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+

+ Edit Configuration +

+ +
+ +
+ + {/* Editor */} +
+ {loading ? ( +
+ + Loading config... +
+ ) : ( +
+ )} +
+ + {/* Footer */} +
+

+ Changes auto-save after editing +

+
+ + Esc + + + to close + +
+
+
+
+ ); +}; + +// ============================================================================= +// Save Status Badge +// ============================================================================= + +const SaveStatusBadge = ({ + status, + error, +}: { + status: SaveStatus; + error: string | null; +}): React.JSX.Element | null => { + if (status === 'idle' && !error) return null; + + if (error && status !== 'saving') { + return ( + + + {status === 'error' ? 'Save failed' : 'Invalid JSON'} + + ); + } + + if (status === 'saving') { + return ( + + + Saving... + + ); + } + + if (status === 'saved') { + return ( + + + Saved + + ); + } + + return null; +}; + +// ============================================================================= +// Editor Theme Override +// ============================================================================= + +const configEditorTheme = EditorView.theme({ + '&': { + height: '100%', + maxHeight: 'calc(85vh - 100px)', + }, + '.cm-scroller': { + overflow: 'auto', + padding: '8px 0', + }, + '.cm-content': { + padding: '0 8px', + }, + '.cm-gutters': { + paddingLeft: '4px', + }, +}); diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index 16471af6..c78078fa 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -366,11 +366,16 @@ export const GeneralSection = ({ confirmLabel: 'Restart', }); if (shouldRelaunch) { - onGeneralToggle('useNativeTitleBar', v); - // Small delay to let config persist before relaunch - setTimeout(() => { - void window.electronAPI?.windowControls?.relaunch(); - }, 200); + // Await config write before relaunch to avoid race condition on Windows + // (antivirus/NTFS can delay file writes beyond a fixed timeout) + try { + await api.config.update('general', { useNativeTitleBar: v }); + } catch { + // If save fails, still try to toggle via the normal path + onGeneralToggle('useNativeTitleBar', v); + await new Promise((r) => setTimeout(r, 500)); + } + void window.electronAPI?.windowControls?.relaunch(); } }} disabled={saving} @@ -503,7 +508,7 @@ export const GeneralSection = ({ {candidate.path}

{!candidate.hasProjectsDir && ( -

+

No projects directory detected

)} diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 06afc588..620108fc 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -150,7 +150,7 @@ type VirtualItem = * Mismatch causes items to overlap! */ const HEADER_HEIGHT = 28; -const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx +const SESSION_HEIGHT = 58; // Must match h-[58px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index eed2c5d6..f32108a1 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState'; import { cn } from '@renderer/lib/utils'; @@ -96,6 +97,7 @@ export const GlobalTaskList = ({ globalTasksLoading, globalTasksInitialized, fetchAllTasks, + softDeleteTask, projects, viewMode, repositoryGroups, @@ -106,6 +108,7 @@ export const GlobalTaskList = ({ globalTasksLoading: s.globalTasksLoading, globalTasksInitialized: s.globalTasksInitialized, fetchAllTasks: s.fetchAllTasks, + softDeleteTask: s.softDeleteTask, projects: s.projects, viewMode: s.viewMode, repositoryGroups: s.repositoryGroups, @@ -145,6 +148,29 @@ export const GlobalTaskList = ({ setRenamingTaskKey(null); }; + const handleDeleteTask = async (teamName: string, taskId: string): Promise => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${taskId} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + await fetchAllTasks(); + } catch (err) { + void confirm({ + title: 'Failed to delete task', + message: err instanceof Error ? err.message : 'An unexpected error occurred', + confirmLabel: 'OK', + variant: 'danger', + }); + } + } + }; + // Fetch tasks on mount — loading guard in the store action prevents // duplicate IPC calls when the centralized init chain is already fetching. useEffect(() => { @@ -224,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]); @@ -329,6 +356,7 @@ export const GlobalTaskList = ({ onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} > taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} > taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} > taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} > + + + Filter logs + + +
+

+ Stream +

+
+ + +
+
+ +
+

+ Content +

+
+ + + +
+
+ +
+ + +
+
+ + ); +}; diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx new file mode 100644 index 00000000..d8cfa28e --- /dev/null +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -0,0 +1,431 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Button } from '@renderer/components/ui/button'; +import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; +import { Search, Terminal, X } from 'lucide-react'; + +import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; +import { CliLogsRichView } from './CliLogsRichView'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; + +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; +} + +function isRecent(updatedAt: string | undefined): boolean { + if (!updatedAt) return false; + const t = Date.parse(updatedAt); + if (Number.isNaN(t)) return false; + return Date.now() - t <= ONLINE_WINDOW_MS; +} + +function normalizeToStreamJsonText(linesNewestFirst: string[]): string { + // We want to feed CliLogsRichView the exact format it expects: + // - marker lines: "[stdout]" / "[stderr]" + // - raw JSON lines without any "[stdout] " prefix + const chronological = [...linesNewestFirst].reverse(); + + const out: string[] = []; + let lastStream: StreamType | null = null; + + const pushMarker = (stream: StreamType): void => { + if (lastStream === stream) return; + lastStream = stream; + out.push(stream === 'stdout' ? '[stdout]' : '[stderr]'); + }; + + for (const rawLine of chronological) { + const line = rawLine ?? ''; + if (line === '[stdout]' || line === '[stderr]') { + lastStream = line === '[stdout]' ? 'stdout' : 'stderr'; + out.push(line); + continue; + } + + if (line.startsWith('[stdout] ')) { + pushMarker('stdout'); + out.push(line.slice('[stdout] '.length)); + continue; + } + if (line.startsWith('[stderr] ')) { + pushMarker('stderr'); + out.push(line.slice('[stderr] '.length)); + continue; + } + + out.push(line); + } + + return out.join('\n'); +} + +type AssistantContentBlock = + | { type: 'text'; text?: string } + | { type: 'thinking'; thinking?: string } + | { type: 'tool_use'; id?: string; name?: string; input?: Record } + | { type: string; [key: string]: unknown }; + +function filterStreamJsonText( + linesNewestFirst: string[], + queryRaw: string, + filter: ClaudeLogsFilterState +): string { + const q = queryRaw.trim().toLowerCase(); + const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n'); + + let currentStream: StreamType | null = null; + let lastEmittedStream: StreamType | null = null; + const out: string[] = []; + + const emitMarker = (): void => { + if (!currentStream) return; + if (lastEmittedStream === currentStream) return; + out.push(currentStream === 'stdout' ? '[stdout]' : '[stderr]'); + lastEmittedStream = currentStream; + }; + + const extractBlocks = (parsed: Record): AssistantContentBlock[] | null => { + if (parsed.type !== 'assistant') return null; + if (Array.isArray(parsed.content)) { + return parsed.content as AssistantContentBlock[]; + } + const msg = parsed.message; + if (msg && typeof msg === 'object') { + const inner = msg as Record; + if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[]; + } + return null; + }; + + const writeBlocks = ( + parsed: Record, + blocks: AssistantContentBlock[] + ): Record => { + if (Array.isArray(parsed.content)) { + return { ...parsed, content: blocks }; + } + const msg = parsed.message; + if (msg && typeof msg === 'object') { + return { ...parsed, message: { ...(msg as Record), content: blocks } }; + } + return parsed; + }; + + for (const rawLine of chronological) { + const line = rawLine.trimEnd(); + if (!line) continue; + + if (line === '[stdout]' || line === '[stderr]') { + currentStream = line === '[stdout]' ? 'stdout' : 'stderr'; + continue; + } + + if (currentStream && !filter.streams.has(currentStream)) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Non-JSON lines are ignored to keep view consistent with CliLogsRichView. + continue; + } + + if (!parsed || typeof parsed !== 'object') continue; + const obj = parsed as Record; + + const blocks = extractBlocks(obj); + if (!blocks) { + // Keep only assistant messages for now (CliLogsRichView renders these richly). + continue; + } + + const filteredBlocks = blocks.filter((b) => { + if (!b || typeof b !== 'object') return false; + if (b.type === 'text') return filter.kinds.has('output'); + if (b.type === 'thinking') return filter.kinds.has('thinking'); + if (b.type === 'tool_use') return filter.kinds.has('tool'); + // Unknown block types: keep (they're rare, and dropping can hide content) + return true; + }); + if (filteredBlocks.length === 0) continue; + + const searchTextParts: string[] = []; + for (const b of filteredBlocks) { + if (b.type === 'text' && typeof b.text === 'string') searchTextParts.push(b.text); + if (b.type === 'thinking' && typeof b.thinking === 'string') searchTextParts.push(b.thinking); + if (b.type === 'tool_use') { + if (typeof b.name === 'string') searchTextParts.push(b.name); + if (b.input && typeof b.input === 'object') { + try { + searchTextParts.push(JSON.stringify(b.input)); + } catch { + // ignore + } + } + } + } + const haystack = searchTextParts.join('\n').toLowerCase(); + if (q && !haystack.includes(q)) { + continue; + } + + emitMarker(); + const nextObj = writeBlocks(obj, filteredBlocks); + out.push(JSON.stringify(nextObj)); + } + + return out.join('\n'); +} + +export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { + const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); + const [pending, setPending] = useState(null); + const [pendingNewCount, setPendingNewCount] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + const atTopRef = useRef(true); + const latestRef = useRef(null); + const logContainerRef = useRef(null); + const committedRef = useRef({ lines: [], total: 0, hasMore: false }); + const pendingCountRef = useRef(0); + const [searchQuery, setSearchQuery] = useState(''); + const [filter, setFilter] = useState(() => ({ + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + })); + const [filterOpen, setFilterOpen] = useState(false); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + setData({ lines: [], total: 0, hasMore: false }); + setPending(null); + setPendingNewCount(0); + latestRef.current = null; + atTopRef.current = true; + setError(null); + setSearchQuery(''); + setFilter({ + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + }); + }, [teamName]); + + useEffect(() => { + committedRef.current = data; + }, [data]); + + useEffect(() => { + pendingCountRef.current = pendingNewCount; + }, [pendingNewCount]); + + useEffect(() => { + let cancelled = false; + + const computeNewCount = ( + committed: TeamClaudeLogsResponse, + latest: TeamClaudeLogsResponse + ): number => { + if (committed.lines.length === 0) return latest.lines.length; + const marker = committed.lines[0]; + const idx = latest.lines.indexOf(marker); + if (idx >= 0) return idx; + const diff = + (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); + return Math.max(0, diff); + }; + + const fetchLogs = async (): Promise => { + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + setLoading(true); + const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); + if (cancelled) return; + latestRef.current = next; + if (atTopRef.current) { + setData(next); + setPending(null); + setPendingNewCount(0); + } else { + setPending(next); + const base = computeNewCount(committedRef.current, next); + setPendingNewCount((prev) => Math.max(prev, base)); + } + setError(null); + } catch (e) { + if (cancelled) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + inFlightRef.current = false; + if (!cancelled) setLoading(false); + } + }; + + void fetchLogs(); + const id = window.setInterval(() => void fetchLogs(), POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [teamName, visibleCount]); + + const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); + const badge = data.total > 0 ? data.total : undefined; + const showMoreVisible = data.hasMore; + + const headerExtra = online ? ( + + + + + ) : null; + + const filteredText = useMemo(() => { + if (data.lines.length === 0) return ''; + const isDefault = + filter.streams.size === DEFAULT_CLAUDE_LOGS_FILTER.streams.size && + filter.kinds.size === DEFAULT_CLAUDE_LOGS_FILTER.kinds.size && + [...DEFAULT_CLAUDE_LOGS_FILTER.streams].every((s) => filter.streams.has(s)) && + [...DEFAULT_CLAUDE_LOGS_FILTER.kinds].every((k) => filter.kinds.has(k)); + + if (!searchQuery.trim() && isDefault) { + return normalizeToStreamJsonText(data.lines); + } + return filterStreamJsonText(data.lines, searchQuery, filter); + }, [data.lines, searchQuery, filter]); + + const applyPending = (): void => { + const latest = latestRef.current ?? pending; + if (!latest) return; + setData(latest); + setPending(null); + setPendingNewCount(0); + // Jump to newest + if (logContainerRef.current) { + logContainerRef.current.scrollTop = 0; + } + }; + + return ( + } + badge={badge} + headerExtra={headerExtra} + defaultOpen + contentClassName="pt-0" + > +
+ + {data.total > 0 ? ( + <> + Showing {Math.min(data.total, visibleCount)} of{' '} + {data.total} + + ) : isAlive ? ( + 'No logs yet.' + ) : ( + 'Team is not running.' + )} + +
+
+ + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + {pendingNewCount > 0 && ( + + )} + {showMoreVisible && ( + + )} +
+
+ +
+ {error ?

{error}

: null} + {!error && filteredText.trim().length > 0 ? ( + { + logContainerRef.current = el; + }} + onScroll={({ scrollTop }) => { + const atTop = scrollTop <= 8; + atTopRef.current = atTop; + if (atTop && pendingCountRef.current > 0) { + applyPending(); + } + }} + /> + ) : null} + {!error && data.lines.length === 0 ? ( +

+ {loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'} +

+ ) : null} + {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( +

No matching logs.

+ ) : null} +
+
+ ); +}; diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 8fbd0d2c..971faecd 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; +import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; import { cn } from '@renderer/lib/utils'; import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { Bot, ChevronRight } from 'lucide-react'; @@ -18,6 +19,11 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; interface CliLogsRichViewProps { cliLogsTail: string; + order?: 'oldest-first' | 'newest-first'; + onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; + containerRefCallback?: (el: HTMLDivElement | null) => void; + /** Optional local search query override for inline highlighting */ + searchQueryOverride?: string; className?: string; } @@ -43,10 +49,12 @@ const FlatGroupItem = ({ group, expandedItemIds, onItemClick, + searchQueryOverride, }: { group: StreamJsonGroup; expandedItemIds: Set; onItemClick: (itemId: string) => void; + searchQueryOverride?: string; }): React.JSX.Element => { const groupItemIds = useMemo( () => scopedItemIds(expandedItemIds, group.id), @@ -63,6 +71,7 @@ const FlatGroupItem = ({ onItemClick={handleItemClick} expandedItemIds={groupItemIds} aiGroupId={group.id} + searchQueryOverride={searchQueryOverride} /> ); }; @@ -76,12 +85,14 @@ const StreamGroup = ({ onToggle, expandedItemIds, onItemClick, + searchQueryOverride, }: { group: StreamJsonGroup; isExpanded: boolean; onToggle: () => void; expandedItemIds: Set; onItemClick: (itemId: string) => void; + searchQueryOverride?: string; }): React.JSX.Element => { // Scope item IDs to this group to avoid cross-group collisions const groupItemIds = useMemo( @@ -109,7 +120,11 @@ const StreamGroup = ({ /> - {group.summary} + {searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, { + forceAllActive: true, + }) + : group.summary} {isExpanded && ( @@ -119,6 +134,7 @@ const StreamGroup = ({ onItemClick={handleItemClick} expandedItemIds={groupItemIds} aiGroupId={group.id} + searchQueryOverride={searchQueryOverride} />
)} @@ -128,9 +144,13 @@ const StreamGroup = ({ export const CliLogsRichView = ({ cliLogsTail, + order = 'oldest-first', + onScroll, + containerRefCallback, + searchQueryOverride, className, }: CliLogsRichViewProps): React.JSX.Element => { - const scrollRef = useRef(null); + const scrollRef = useRef(null); // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); @@ -151,9 +171,13 @@ export const CliLogsRichView = ({ // Auto-scroll to bottom on new content useEffect(() => { if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (order === 'newest-first') { + scrollRef.current.scrollTop = 0; + } else { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } } - }, [cliLogsTail]); + }, [cliLogsTail, order]); const handleGroupToggle = useCallback((groupId: string) => { setCollapsedGroupIds((prev) => { @@ -184,11 +208,18 @@ export const CliLogsRichView = ({ const hasContent = cliLogsTail.trim().length > 0; return (
{ + scrollRef.current = el; + containerRefCallback?.(el); + }} className={cn( 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', className )} + onScroll={(e) => { + const el = e.currentTarget; + onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + }} > {hasContent ? (
@@ -203,9 +234,21 @@ export const CliLogsRichView = ({
     );
   }
 
+  const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
+
   return (
-    
- {groups.map((group) => +
{ + scrollRef.current = el; + containerRefCallback?.(el); + }} + className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)} + onScroll={(e) => { + const el = e.currentTarget; + onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + }} + > + {visibleGroups.map((group) => group.items.length === 1 ? ( // Single item — render flat without collapsible group wrapper ) : ( handleGroupToggle(group.id)} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} /> ) )} diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index d82fc53a..583ba823 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -114,7 +114,7 @@ export const CollapsibleTeamSection = ({ {action &&
{action}
}
{isOpen && ( -
+
{children}
)} diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 29c7e229..b168d71d 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -44,27 +44,67 @@ function formatElapsed(seconds: number): string { return `${m}:${String(s).padStart(2, '0')}`; } -function useElapsedTimer(startedAt?: string): string | null { - const [elapsed, setElapsed] = useState(null); +function useElapsedTimer(startedAt?: string, isRunning = true): string | null { + const [elapsedSeconds, setElapsedSeconds] = useState(null); useEffect(() => { - if (!startedAt) return () => setElapsed(null); + if (!startedAt) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + setElapsedSeconds(null); + return; + } + const startMs = Date.parse(startedAt); - if (isNaN(startMs)) return () => setElapsed(null); + if (isNaN(startMs)) { + setElapsedSeconds(null); + return; + } + + const computeElapsedSeconds = (): number => + Math.max(0, Math.floor((Date.now() - startMs) / 1000)); + + if (!isRunning) { + // Freeze timer on terminal states (failed/ready/cancelled) instead of continuing to tick. + setElapsedSeconds((prev) => (prev === null ? computeElapsedSeconds() : prev)); + return; + } const tick = (): void => { - const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); - setElapsed(formatElapsed(seconds)); + setElapsedSeconds(computeElapsedSeconds()); }; + tick(); const id = window.setInterval(tick, 1000); return () => { window.clearInterval(id); }; - }, [startedAt]); + }, [startedAt, isRunning]); if (!startedAt) return null; - return elapsed; + if (elapsedSeconds === null) return null; + return formatElapsed(elapsedSeconds); +} + +function sanitizeAssistantOutput(raw?: string, isError = false): string | null { + if (!raw) return null; + if (!isError) return raw; + + const looksLikeRawApiEnvelope = + raw.includes('API Error: 400') && + (raw.includes('"_requests"') || + raw.includes('"session_id"') || + raw.includes('"parent_tool_use_id"') || + raw.includes('\\u000')); + + if (!looksLikeRawApiEnvelope) { + return raw; + } + + return ( + 'API Error: 400\n\n' + + 'Raw payload from CLI stream hidden because it contains encoded/binary-like content.\n\n' + + 'Open **CLI logs** below for readable diagnostics.' + ); } export const ProvisioningProgressBlock = ({ @@ -81,11 +121,12 @@ export const ProvisioningProgressBlock = ({ assistantOutput, className, }: ProvisioningProgressBlockProps): React.JSX.Element => { - const elapsed = useElapsedTimer(startedAt); - const [logsOpen, setLogsOpen] = useState(false); + const elapsed = useElapsedTimer(startedAt, loading); + const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail)); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; + const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); // Auto-scroll assistant output useEffect(() => { @@ -99,6 +140,14 @@ export const ProvisioningProgressBlock = ({ setLiveOutputOpen(defaultLiveOutputOpen); }, [defaultLiveOutputOpen]); + // On error with logs available, prioritize logs view over noisy live stream payload. + useEffect(() => { + if (isError && cliLogsTail) { + setLogsOpen(true); + setLiveOutputOpen(false); + } + }, [isError, cliLogsTail]); + return (
{message}

) : null} -
+
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; - return (
- {/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */} @@ -165,7 +213,6 @@ export const ProvisioningProgressBlock = ({ {STEP_LABELS[step]} - {/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */} {index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? ( ) : null} @@ -190,13 +237,13 @@ export const ProvisioningProgressBlock = ({ isError && 'border-red-500/40' )} > - {assistantOutput ? ( - + {displayAssistantOutput ? ( + ) : (

No output captured yet. diff --git a/src/renderer/components/team/RoleSelect.tsx b/src/renderer/components/team/RoleSelect.tsx new file mode 100644 index 00000000..4e091f14 --- /dev/null +++ b/src/renderer/components/team/RoleSelect.tsx @@ -0,0 +1,149 @@ +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 type { ComboboxOption } from '@renderer/components/ui/combobox'; +import type { LucideIcon } from 'lucide-react'; + +/** Icon mapping for preset roles. */ +const ROLE_ICONS: Record = { + architect: Blocks, + reviewer: BookOpen, + developer: Code2, + qa: Bug, + researcher: BookOpen, + docs: FileText, + auditor: Shield, + optimizer: Zap, +}; + +const CUSTOM_ICON = Pencil; + +interface RoleSelectProps { + /** Current role selection value (preset role name, CUSTOM_ROLE, or NO_ROLE). */ + value: string; + /** Called when the user picks a preset role, NO_ROLE, or CUSTOM_ROLE. */ + onValueChange: (value: string) => void; + /** Current custom role text (only relevant when value === CUSTOM_ROLE). */ + customRole?: string; + /** Called when the user types a custom role. */ + onCustomRoleChange?: (customRole: string) => void; + /** Trigger height class, e.g. "h-7" or "h-8". */ + triggerClassName?: string; + /** Custom input height class. */ + inputClassName?: string; + /** Show validation error for custom role. */ + customRoleError?: string | null; + /** Validate custom role on change and return error or null. */ + onCustomRoleValidate?: (role: string) => string | null; + disabled?: boolean; +} + +const roleOptions: ComboboxOption[] = [ + { value: NO_ROLE, label: 'No role' }, + ...PRESET_ROLES.map((role) => ({ + value: role, + label: role, + })), + { 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); + + return ( + <> + + {isSelected ? ( + + ) : Icon ? ( + + ) : null} + + {option.label} + + ); +}; + +export const RoleSelect = ({ + value, + onValueChange, + customRole = '', + onCustomRoleChange, + triggerClassName, + inputClassName, + customRoleError: externalError, + onCustomRoleValidate, + disabled, +}: RoleSelectProps): React.JSX.Element => { + const [internalError, setInternalError] = useState(null); + const error = externalError ?? internalError; + + const handleValueChange = useCallback( + (newValue: string) => { + onValueChange(newValue); + if (newValue !== CUSTOM_ROLE) { + setInternalError(null); + } + }, + [onValueChange] + ); + + const handleCustomChange = useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value; + onCustomRoleChange?.(val); + + if (onCustomRoleValidate) { + setInternalError(onCustomRoleValidate(val)); + } else if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) { + setInternalError('This role is reserved'); + } else { + setInternalError(null); + } + }, + [onCustomRoleChange, onCustomRoleValidate] + ); + + const selectedLabel = useMemo(() => { + const opt = roleOptions.find((o) => o.value === value); + return opt?.label; + }, [value]); + + return ( +

+ + {value === CUSTOM_ROLE && onCustomRoleChange ? ( +
+ + {error ? {error} : null} +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx new file mode 100644 index 00000000..f260dccc --- /dev/null +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -0,0 +1,127 @@ +import { useMemo } from 'react'; + +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +/** + * Status/kanban-column display colors. + * Matches the kanban column palette from KanbanBoard.tsx. + */ +const STATUS_COLORS: Record = { + pending: { text: '#60a5fa', bg: 'rgba(59, 130, 246, 0.15)' }, // blue + todo: { text: '#60a5fa', bg: 'rgba(59, 130, 246, 0.15)' }, + in_progress: { text: '#facc15', bg: 'rgba(234, 179, 8, 0.15)' }, // yellow + completed: { text: '#4ade80', bg: 'rgba(34, 197, 94, 0.15)' }, // green + done: { text: '#4ade80', bg: 'rgba(34, 197, 94, 0.15)' }, + review: { text: '#a78bfa', bg: 'rgba(139, 92, 246, 0.15)' }, // purple + approved: { text: '#34d399', bg: 'rgba(34, 197, 94, 0.25)' }, // bright green + deleted: { text: '#f87171', bg: 'rgba(239, 68, 68, 0.15)' }, // red +}; + +function getEffectiveColumn(task: TeamTaskWithKanban): string { + if (task.kanbanColumn) return task.kanbanColumn; + if (task.status === 'pending') return 'todo'; + if (task.status === 'completed') return 'done'; + return task.status; +} + +function getStatusLabel(column: string): string { + const labels: Record = { + todo: 'To Do', + pending: 'To Do', + in_progress: 'In Progress', + done: 'Done', + completed: 'Done', + review: 'Review', + approved: 'Approved', + deleted: 'Deleted', + }; + return labels[column] ?? column; +} + +interface TaskTooltipProps { + /** The task ID (number string, e.g. "10"). */ + taskId: string; + /** Rendered trigger element. */ + children: React.ReactElement; + /** Tooltip placement. */ + side?: 'top' | 'bottom' | 'left' | 'right'; +} + +/** + * Tooltip that shows task summary on hover over any #taskId link. + * Reads task data from the current team in the store. + */ +export const TaskTooltip = ({ + taskId, + children, + side = 'top', +}: TaskTooltipProps): React.JSX.Element => { + const tasks = useStore((s) => s.selectedTeamData?.tasks); + const members = useStore((s) => s.selectedTeamData?.members); + + const task = useMemo( + () => tasks?.find((t) => t.id === taskId), + [tasks, taskId] + ); + + const colorMap = useMemo( + () => (members ? buildMemberColorMap(members) : new Map()), + [members] + ); + + // If task not found, render children without tooltip + if (!task) return children; + + const column = getEffectiveColumn(task); + const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending; + const label = getStatusLabel(column); + + return ( + + {children} + + {/* Subject */} +
+ #{taskId}{' '} + {task.subject} +
+ + {/* Status badge */} +
+ + {label} + + + {/* Owner */} + {task.owner ? ( + + ) : ( + Unassigned + )} +
+ + {/* Description — full markdown with scroll */} + {task.description ? ( +
+ +
+ ) : null} +
+
+ ); +}; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 1c2f7a40..7b6cb95d 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,6 +1,7 @@ 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 { @@ -14,16 +15,19 @@ 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 { createChipFromSelection } from '@renderer/utils/chipUtils'; +import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { AlertTriangle, Bell, @@ -69,6 +73,7 @@ import { MemberList } from './members/MemberList'; import { MessageComposer } from './messages/MessageComposer'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; +import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; @@ -76,6 +81,7 @@ import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { MessagesFilterState } from './messages/MessagesFilterPopover'; +import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -181,6 +187,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [kanbanFilter, setKanbanFilter] = useState({ sessionId: null, selectedOwners: new Set(), + columns: new Set(), }); const { @@ -190,6 +197,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele projects, repositoryGroups, teams, + fetchSessionDetail, + initTabUIState, selectTeam, updateKanban, updateKanbanColumnOrder, @@ -228,6 +237,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele projects: s.projects, repositoryGroups: s.repositoryGroups, teams: s.teams, + fetchSessionDetail: s.fetchSessionDetail, + initTabUIState: s.initTabUIState, selectTeam: s.selectTeam, updateKanban: s.updateKanban, updateKanbanColumnOrder: s.updateKanbanColumnOrder, @@ -263,6 +274,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele })) ); + // Per-tab UI state (context panel visibility + selected phase) + const { + tabId, + isContextPanelVisible, + setContextPanelVisible, + selectedContextPhase, + setSelectedContextPhase, + } = useTabUI(); + const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); + + useEffect(() => { + if (tabId) { + initTabUIState(tabId); + } + }, [tabId, initTabUIState]); + useEffect(() => { const wasProvisioning = wasProvisioningRef.current; wasProvisioningRef.current = isTeamProvisioning; @@ -276,6 +303,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [messagesFilter, setMessagesFilter] = useState({ from: new Set(), to: new Set(), + showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); @@ -334,6 +362,113 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele [projects, repositoryGroups, data?.config.projectPath] ); + // 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 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 leadSubagentCostUsd = useMemo(() => { + const processes = leadSessionDetail?.processes; + if (!processes || processes.length === 0) return undefined; + const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); + return total > 0 ? total : undefined; + }, [leadSessionDetail?.processes]); + const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { + if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { + return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; + } + + // Determine which phase to show + const effectivePhase = selectedContextPhase; + + // If a specific phase is selected, find the last AI group in that phase + let targetAiGroupId: string | undefined; + if (effectivePhase !== null && leadSessionPhaseInfo) { + const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); + if (phase) { + targetAiGroupId = phase.lastAIGroupId; + } + } + + // Default: use the last AI group overall + if (!targetAiGroupId) { + const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai'); + if (lastAiItem?.type !== 'ai') { + return { + allContextInjections: [] as ContextInjection[], + lastAiGroupTotalTokens: undefined, + }; + } + targetAiGroupId = lastAiItem.group.id; + } + + const stats = leadSessionContextStats.get(targetAiGroupId); + const injections = stats?.accumulatedInjections ?? []; + + // Get total tokens from the target AI group + let totalTokens: number | undefined; + const targetItem = leadConversation.items.find( + (item) => item.type === 'ai' && item.group.id === targetAiGroupId + ); + if (targetItem?.type === 'ai') { + const responses = targetItem.group.responses || []; + for (let i = responses.length - 1; i >= 0; i--) { + const msg = responses[i]; + if (msg.type === 'assistant' && msg.usage) { + const usage = msg.usage; + totalTokens = + (usage.input_tokens ?? 0) + + (usage.output_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0); + break; + } + } + } + + return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; + }, [ + leadSessionLoaded, + leadSessionContextStats, + leadConversation, + selectedContextPhase, + leadSessionPhaseInfo, + ]); + + const visibleContextTokens = useMemo( + () => sumContextInjectionTokens(allContextInjections), + [allContextInjections] + ); + const visibleContextPercentLabel = useMemo( + () => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens), + [visibleContextTokens, lastAiGroupTotalTokens] + ); + + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + + // Keep lead-session context fresh in the background while the team tab is active. + // This keeps the button value current even when the panel is closed. + useEffect(() => { + if (!isThisTabActive) return; + if (!tabId || !projectId || !leadSessionId) return; + if (!data?.isAlive) return; + + const tick = (): void => { + void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); + }; + tick(); + const id = window.setInterval(tick, 30_000); + return () => window.clearInterval(id); + }, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]); + useEffect(() => { if (!projectId) return; @@ -448,6 +583,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return ts >= timeWindow.start && ts < timeWindow.end; }); } + if (!messagesFilter.showNoise) { + list = list.filter((m) => !isInboxNoiseMessage(typeof m.text === 'string' ? m.text : '')); + } if (messagesFilter.from.size > 0) { list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim())); } @@ -618,7 +756,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele variant: 'danger', }); if (confirmed) { - void softDeleteTask(teamName, taskId); + try { + await softDeleteTask(teamName, taskId); + } catch { + // error via store + } } })(); }, @@ -744,404 +886,912 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return ( <> -
+
- {headerColorSet ? ( + {/* Context button pinned to bottom-right of viewport */} + {leadSessionId && (
- ) : null} -
-
-

- {data.config.name} -

+ className="pointer-events-none fixed bottom-4 z-20" + style={{ right: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }} + > +
-
- {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 + + {messagesUnreadCount > 0 && ( + + + + + Mark all as read + + )} + + } + defaultOpen + action={ +
+
+ + 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 requestReview(teamName, taskId); - }} - onApprove={(taskId) => { - void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - })(); - }} - onStartTask={(taskId) => { + 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} + /> + + 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 { - 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 - } - } + await addMember(teamName, { name, role, workflow }); + setAddMemberDialogOpen(false); } catch { - // error via store + // error shown via store + } finally { + setAddingMemberLoading(false); } })(); }} - onCompleteTask={(taskId) => { - void updateTaskStatus(teamName, taskId, 'completed'); + /> + + { + if (!open) setRemoveMemberConfirm(null); }} - onCancelTask={(taskId) => { + > + + + 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 { - 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 - } - } + await sendTeamMessage(teamName, { member, text, summary, attachments }); } catch { - // error via store + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); } })(); }} - onColumnOrderChange={(columnId, orderedTaskIds) => { - void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + 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' }); @@ -1149,443 +1799,104 @@ 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} - defaultOpen - > - - - )} + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - } - 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" - /> -
- + 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 */} + {isContextPanelVisible && leadSessionId && ( +
+ {leadSessionLoaded ? ( + setContextPanelVisible(false)} + projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath} + totalSessionTokens={lastAiGroupTotalTokens} + sessionMetrics={leadSessionDetail?.metrics} + subagentCostUsd={leadSubagentCostUsd} + phaseInfo={leadSessionPhaseInfo ?? undefined} + selectedPhase={selectedContextPhase} + onPhaseChange={setSelectedContextPhase} /> -
- } - > - { - 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 - } - })(); - }} - /> - - 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)} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(name, role) => { - setAddingMemberLoading(true); - void (async () => { - try { - await addMember(teamName, { name, role }); - 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 updateTaskOwner(teamName, taskId, owner); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void restoreTask(teamName, taskId); - }} - /> - - - 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} - /> +
+
+

Visible Context

+

+ {leadSessionLoading ? 'Loading…' : 'No session loaded'} +

+
+ +
+
+

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

+
+
+ )} +
+ )}
{editorOpen && data.config.projectPath && ( diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index db92b3d4..7524b49d 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -255,6 +255,21 @@ export const TeamListView = (): React.JSX.Element => { }; }, [electronMode, teams]); + // Refresh alive teams when opening the create dialog so conflict warning is accurate. + useEffect(() => { + if (!electronMode || !showCreateDialog) return; + let cancelled = false; + void api.teams + .aliveList() + .then((list) => { + if (!cancelled) setAliveTeams(list); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [electronMode, showCreateDialog]); + const currentProjectPath = useMemo(() => { if (viewMode === 'grouped') { const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); @@ -367,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => { variant: 'danger', }); if (confirmed) { - void deleteTeam(teamName); + try { + await deleteTeam(teamName); + } catch { + // error via store + } } })(); }, @@ -377,7 +396,13 @@ export const TeamListView = (): React.JSX.Element => { const handleRestoreTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); - void restoreTeam(teamName); + void (async () => { + try { + await restoreTeam(teamName); + } catch { + // error via store + } + })(); }, [restoreTeam] ); @@ -394,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => { variant: 'danger', }); if (confirmed) { - void permanentlyDeleteTeam(teamName); + try { + await permanentlyDeleteTeam(teamName); + } catch { + // error via store + } } })(); }, @@ -527,10 +556,14 @@ export const TeamListView = (): React.JSX.Element => {
diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index c96ba6f6..20006dae 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -78,11 +78,11 @@ export const TeamProvisioningBanner = ({ return (
-

{progress.message}

+

{progress.message}

+ + + ); }); } @@ -233,25 +235,30 @@ export const ActivityItem = ({ const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const [isExpanded, setIsExpanded] = useState(!systemLabel); - // Strip agent-only blocks from displayed text + linkify task IDs + @mentions - const displayText = useMemo(() => { + // Strip agent-only blocks + normalize escape sequences (before linkification) + const strippedText = useMemo(() => { if (structured) return null; const stripped = stripAgentBlocks(message.text).trim(); if (!stripped) return null; // All content was agent-only blocks → show summary instead // Normalize literal \n from CLI tools (teamctl.js) to real newlines - const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); - let result = normalized; - if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result); + return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); + }, [structured, message.text]); + + // Parse reply BEFORE linkification — linkifyMentionsInMarkdown transforms @name + // into markdown links which breaks the reply regex matcher + const parsedReply = useMemo( + () => (strippedText ? parseMessageReply(strippedText) : null), + [strippedText] + ); + + // Linkify task IDs (always, for TaskTooltip) + @mentions for display + const displayText = useMemo(() => { + if (!strippedText) return null; + let result = linkifyTaskIdsInMarkdown(strippedText); if (memberColorMap && memberColorMap.size > 0) result = linkifyMentionsInMarkdown(result, memberColorMap); return result; - }, [structured, message.text, onTaskIdClick, memberColorMap]); - - // Check if this is a reply message - const parsedReply = useMemo( - () => (displayText ? parseMessageReply(displayText) : null), - [displayText] - ); + }, [strippedText, memberColorMap]); const rawSummary = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; @@ -276,25 +283,34 @@ export const ActivityItem = ({ }; const isHeaderClickable = Boolean(systemLabel); + const isUserSent = message.source === 'user_sent'; + const isSystemMessage = message.from === 'system'; return (
{/* Header — div with role=button (cannot use
) : parsedReply ? ( - + ) : displayText ? ( - { - const link = (e.target as HTMLElement).closest( - 'a[href^="task://"]' - ); - if (link) { - e.preventDefault(); - e.stopPropagation(); - const taskId = link.getAttribute('href')?.replace('task://', ''); - if (taskId) onTaskIdClick(taskId); + + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const taskId = link.getAttribute('href')?.replace('task://', ''); + if (taskId) onTaskIdClick(taskId); + } } - } - : undefined - } - > - - + : undefined + } + > + + + ) : summaryText ? (

{summaryText} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index a4033cad..cb8a7331 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; +import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; +import type { TimelineItem } from './LeadThoughtsGroup'; interface ActivityTimelineProps { messages: InboxMessage[]; @@ -158,7 +159,7 @@ export const ActivityTimeline = ({ if (leadMember) { const leadInfo = memberInfo.get(leadMember.name); if (leadInfo) { - memberInfo.set('user', { role: leadInfo.role, color: colorMap.get('user') }); + memberInfo.set('user', { role: undefined, color: colorMap.get('user') }); } } } @@ -185,29 +186,50 @@ export const ActivityTimeline = ({ [messages, visibleCount, hiddenCount] ); - // Zebra striping: alternate shade on non-noise (full card) messages only. + // Group consecutive lead thoughts into collapsible blocks. + const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); + + // Zebra striping: alternate shade on non-noise (full card) items only. const zebraShadeSet = useMemo(() => { const result = new Set(); let cardCount = 0; - for (let i = 0; i < visibleMessages.length; i++) { - if (isNoiseMessage(visibleMessages[i].text)) continue; - if (cardCount % 2 === 1) result.add(i); - cardCount++; + for (let i = 0; i < timelineItems.length; i++) { + const item = timelineItems[i]; + if (item.type === 'lead-thoughts') { + // Thought groups count as one card for striping + if (cardCount % 2 === 1) result.add(i); + cardCount++; + } else { + if (isNoiseMessage(item.message.text)) continue; + if (cardCount % 2 === 1) result.add(i); + cardCount++; + } } return result; - }, [visibleMessages]); + }, [timelineItems]); - // Determine which messages are "new" (should animate). + // Determine which items are "new" (should animate). - const newMessageKeys = useMemo(() => { - const getKey = (msg: InboxMessage, idx: number): string => - `${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`; + const newItemKeys = useMemo(() => { + const getItemKey = (item: TimelineItem): string => { + if (item.type === 'lead-thoughts') { + // Stable key: identify group by its first thought, not by count (which changes) + return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}`; + } + const msg = item.message; + return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`; + }; + + const allKeys: string[] = []; + for (const item of timelineItems) { + allKeys.push(getItemKey(item)); + } // First render: seed known keys, no animations if (!isInitializedRef.current) { isInitializedRef.current = true; - for (let i = 0; i < visibleMessages.length; i++) { - knownKeysRef.current.add(getKey(visibleMessages[i], i)); + for (const key of allKeys) { + knownKeysRef.current.add(key); } prevVisibleCountRef.current = visibleCount; return new Set(); @@ -218,23 +240,22 @@ export const ActivityTimeline = ({ prevVisibleCountRef.current = visibleCount; if (isPaginationExpansion) { - for (let i = 0; i < visibleMessages.length; i++) { - knownKeysRef.current.add(getKey(visibleMessages[i], i)); + for (const key of allKeys) { + knownKeysRef.current.add(key); } return new Set(); } - // Normal update: unknown keys are new messages + // Normal update: unknown keys are new items const newKeys = new Set(); - for (let i = 0; i < visibleMessages.length; i++) { - const key = getKey(visibleMessages[i], i); + for (const key of allKeys) { if (!knownKeysRef.current.has(key)) { newKeys.add(key); knownKeysRef.current.add(key); } } return newKeys; - }, [visibleMessages, visibleCount]); + }, [timelineItems, visibleCount]); /* eslint-enable react-hooks/refs -- end animation tracking block */ const handleShowMore = (): void => { @@ -254,37 +275,83 @@ export const ActivityTimeline = ({ ); } + const getItemSessionId = (item: TimelineItem): string | undefined => + item.type === 'lead-thoughts' + ? item.group.thoughts[0].leadSessionId + : item.message.leadSessionId; + return (

- {visibleMessages.map((message, index) => { + {timelineItems.map((item, index) => { + // Session boundary separator (messages sorted desc — new on top) + let sessionSeparator: React.JSX.Element | null = null; + if (index > 0) { + const prevSessionId = getItemSessionId(timelineItems[index - 1]); + const currSessionId = getItemSessionId(item); + if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { + sessionSeparator = ( +
+
+ + New session + +
+
+ ); + } + } + + if (item.type === 'lead-thoughts') { + const { group } = item; + const firstThought = group.thoughts[0]; + const info = memberInfo.get(firstThought.from); + const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`; + return ( + + {sessionSeparator} + + + ); + } + + const { message } = item; const info = memberInfo.get(message.from); const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientColor = recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); - const globalIndex = index; - const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`; + const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; return ( - + + {sessionSeparator} + + ); })} {hiddenCount > 0 && ( diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx new file mode 100644 index 00000000..0d85a943 --- /dev/null +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { + CARD_BG, + CARD_BORDER_STYLE, + CARD_ICON_MUTED, + CARD_TEXT_LIGHT, +} from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; + +import type { InboxMessage } from '@shared/types'; + +export interface LeadThoughtGroup { + type: 'lead-thoughts'; + thoughts: InboxMessage[]; +} + +/** + * Check if a message is an intermediate lead "thought" (assistant text) rather than + * an official message (SendMessage, direct reply, inbox, etc.). + */ +export function isLeadThought(msg: InboxMessage): boolean { + if (msg.source === 'lead_session') return true; + if (msg.source === 'lead_process' && msg.messageId?.startsWith('lead-text-')) return true; + return false; +} + +export type TimelineItem = + | { type: 'message'; message: InboxMessage; originalIndex: number } + | { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] }; + +/** + * Group consecutive lead thoughts into collapsible blocks. + * Single thoughts remain as regular messages. + */ +export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { + const result: TimelineItem[] = []; + let pendingThoughts: InboxMessage[] = []; + let pendingIndices: number[] = []; + + const flushThoughts = (): void => { + if (pendingThoughts.length === 0) return; + if (pendingThoughts.length === 1) { + result.push({ + type: 'message', + message: pendingThoughts[0], + originalIndex: pendingIndices[0], + }); + } else { + result.push({ + type: 'lead-thoughts', + group: { type: 'lead-thoughts', thoughts: pendingThoughts }, + originalIndices: pendingIndices, + }); + } + pendingThoughts = []; + pendingIndices = []; + }; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (isLeadThought(msg)) { + pendingThoughts.push(msg); + pendingIndices.push(i); + } else { + flushThoughts(); + result.push({ type: 'message', message: msg, originalIndex: i }); + } + } + flushThoughts(); + return result; +} + +const VIEWPORT_THRESHOLD = 0.15; +const LIVE_WINDOW_MS = 10_000; +const AUTO_SCROLL_THRESHOLD = 30; + +interface LeadThoughtsGroupRowProps { + group: LeadThoughtGroup; + memberColor?: string; + isNew?: boolean; + onVisible?: (message: InboxMessage) => void; +} + +function formatTime(timestamp: string): string { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function formatTimeWithSec(timestamp: string): string { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function isRecentTimestamp(timestamp: string): boolean { + const t = Date.parse(timestamp); + if (Number.isNaN(t)) return false; + return Date.now() - t <= LIVE_WINDOW_MS; +} + +export const LeadThoughtsGroupRow = ({ + group, + memberColor, + isNew, + onVisible, +}: LeadThoughtsGroupRowProps): React.JSX.Element => { + const ref = useRef(null); + const scrollRef = useRef(null); + const isUserScrolledUpRef = useRef(false); + + const colors = getTeamColorSet(memberColor ?? ''); + const { thoughts } = group; + // thoughts is newest-first; first=newest, last=oldest + const newest = thoughts[0]; + const oldest = thoughts[thoughts.length - 1]; + const leadName = newest.from; + + // Chronological order for rendering (oldest at top, newest at bottom) + const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]); + + // Live indicator: newest thought is from lead_process and recent + const computeIsLive = useCallback( + () => newest.source === 'lead_process' && isRecentTimestamp(newest.timestamp), + [newest.source, newest.timestamp] + ); + const [isLive, setIsLive] = useState(computeIsLive); + + useEffect(() => { + setIsLive(computeIsLive()); + const id = window.setInterval(() => setIsLive(computeIsLive()), 1000); + return () => window.clearInterval(id); + }, [computeIsLive]); + + // Track how many thoughts have been reported as visible so far. + const reportedCountRef = useRef(0); + + useEffect(() => { + if (!onVisible) return; + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return; + const alreadyReported = reportedCountRef.current; + if (alreadyReported >= thoughts.length) return; + for (let i = alreadyReported; i < thoughts.length; i++) { + onVisible(thoughts[i]); + } + reportedCountRef.current = thoughts.length; + }, + { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [onVisible, thoughts]); + + // Auto-scroll to bottom when new thoughts arrive + useEffect(() => { + if (isUserScrolledUpRef.current) return; + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [thoughts.length]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD; + }, []); + + return ( +
+
+ {/* Header */} +
+ {/* Live / offline indicator */} + {isLive ? ( + + + + + ) : ( + + )} + + + {thoughts.length} thoughts + + + {formatTime(oldest.timestamp)}–{formatTime(newest.timestamp)} + +
+ + {/* Scrollable body — fixed height, always visible */} +
+ {chronologicalThoughts.map((thought, idx) => ( +
+ + {formatTimeWithSec(thought.timestamp)} + + + {thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text} + +
+ ))} +
+
+
+ ); +}; diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 29efede0..7483c8f2 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -1,29 +1,65 @@ +import { useState } from 'react'; + import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; interface ReplyQuoteBlockProps { reply: ParsedMessageReply; + /** Color name for the quoted agent (resolved from memberColorMap). */ + memberColor?: string; /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */ bodyMaxHeight?: string; } +/** Threshold (characters) above which the "more/less" toggle is shown. */ +const LONG_QUOTE_THRESHOLD = 200; + export const ReplyQuoteBlock = ({ reply, + memberColor, bodyMaxHeight = 'max-h-56', -}: ReplyQuoteBlockProps): React.JSX.Element => ( -
-
- - @{reply.agentName} - -
- +}: ReplyQuoteBlockProps): React.JSX.Element => { + const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; + const [expanded, setExpanded] = useState(false); + + const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; + + return ( +
+ {/* Quote block — styled like SendMessageDialog */} +
+ {/* Decorative quotation mark */} + + “ + + + {/* "Replying to" + MemberBadge */} +
+ Replying to + +
+ + {/* Quote text */} +
+ +
+ + {/* More/less toggle */} + {isLong ? ( + + ) : null}
+ + {/* Reply text */} +
- -
-); + ); +}; diff --git a/src/renderer/components/team/attachments/AttachmentDisplay.tsx b/src/renderer/components/team/attachments/AttachmentDisplay.tsx index b3461d6a..cd6291c8 100644 --- a/src/renderer/components/team/attachments/AttachmentDisplay.tsx +++ b/src/renderer/components/team/attachments/AttachmentDisplay.tsx @@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
{lightboxIndex !== null && items[lightboxIndex] ? ( setLightboxIndex(null)} + slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))} + index={lightboxIndex} /> ) : null} diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx index 0a97c7c2..819ebaf6 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -1,5 +1,5 @@ import { formatFileSize } from '@renderer/utils/attachmentUtils'; -import { X } from 'lucide-react'; +import { Ban, X } from 'lucide-react'; import { AttachmentThumbnail } from './AttachmentThumbnail'; @@ -8,16 +8,23 @@ import type { AttachmentPayload } from '@shared/types'; interface AttachmentPreviewItemProps { attachment: AttachmentPayload; onRemove: (id: string) => void; + disabled?: boolean; } export const AttachmentPreviewItem = ({ attachment, onRemove, + disabled, }: AttachmentPreviewItemProps): React.JSX.Element => { const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`; return (
+ {disabled ? ( +
+ +
+ ) : null}
diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index d2571ed1..cb86dec8 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -8,12 +8,18 @@ interface AttachmentPreviewListProps { attachments: AttachmentPayload[]; onRemove: (id: string) => void; error?: string | null; + /** When true, previews are overlaid with a disabled indicator (recipient doesn't support attachments). */ + disabled?: boolean; + /** Hint text shown when disabled and attachments are present. */ + disabledHint?: string; } export const AttachmentPreviewList = ({ attachments, onRemove, error, + disabled, + disabledHint, }: AttachmentPreviewListProps): React.JSX.Element | null => { if (attachments.length === 0 && !error) return null; @@ -22,10 +28,24 @@ export const AttachmentPreviewList = ({ {attachments.length > 0 ? (
{attachments.map((att) => ( - + ))}
) : null} + {disabled && disabledHint && attachments.length > 0 ? ( +
+ +

{disabledHint}

+
+ ) : null} {error ? (
diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx index 34169fe9..f5733612 100644 --- a/src/renderer/components/team/attachments/ImageLightbox.tsx +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -1,58 +1,86 @@ -import { useCallback, useEffect } from 'react'; +import 'yet-another-react-lightbox/styles.css'; +import 'yet-another-react-lightbox/plugins/counter.css'; -interface ImageLightboxProps { +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 type { Plugin, Slide } from 'yet-another-react-lightbox'; + +export interface ImageLightboxSlide { src: string; alt?: string; + title?: string; +} + +interface ImageLightboxProps { open: boolean; onClose: () => void; + /** Array of slides for gallery mode. */ + slides?: ImageLightboxSlide[]; + /** Starting slide index (default: 0). */ + index?: number; + /** Single image src — convenience shorthand for `slides={[{ src }]}`. */ + src?: string; + /** Alt text for single-image mode. */ + alt?: string; + enableZoom?: boolean; + enableFullscreen?: boolean; + showCounter?: boolean; } export const ImageLightbox = ({ - src, - alt = 'Image', open, onClose, + slides: slidesProp, + index = 0, + src, + alt, + enableZoom = true, + enableFullscreen = true, + showCounter, }: ImageLightboxProps): React.JSX.Element | null => { - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, - [onClose] - ); + const slides = useMemo(() => { + if (slidesProp && slidesProp.length > 0) { + return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title })); + } + if (src) { + return [{ src, alt }]; + } + return []; + }, [slidesProp, src, alt]); - useEffect(() => { - if (!open) return; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [open, handleKeyDown]); + const plugins = useMemo(() => { + const list: Plugin[] = []; + if (enableZoom) list.push(Zoom); + if (enableFullscreen) list.push(Fullscreen); + // Show counter only when multiple slides (unless explicitly set) + const shouldShowCounter = showCounter ?? slides.length > 1; + if (shouldShowCounter) list.push(Counter); + return list; + }, [enableZoom, enableFullscreen, showCounter, slides.length]); - if (!open) return null; + if (!open || slides.length === 0) return null; return ( -
- -
+ ); }; diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 23fa6ae2..a87dd6b1 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -11,16 +12,15 @@ import { } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; -import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; +import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; +import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; +import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { Loader2 } from 'lucide-react'; +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { ResolvedTeamMember } from '@shared/types'; + const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/; interface AddMemberDialogProps { @@ -28,8 +28,12 @@ interface AddMemberDialogProps { teamName: string; existingNames: string[]; onClose: () => void; - onAdd: (name: string, role?: string) => void; + onAdd: (name: string, role?: string, workflow?: string) => void; adding?: boolean; + /** Project path for @file mentions in workflow field. */ + projectPath?: string | null; + /** Existing team members for @mention suggestions. */ + existingMembers?: ResolvedTeamMember[]; } export const AddMemberDialog = ({ @@ -39,12 +43,36 @@ export const AddMemberDialog = ({ onClose, onAdd, adding, + projectPath, + existingMembers = [], }: AddMemberDialogProps): React.JSX.Element => { const [name, setName] = useState(''); const [roleSelect, setRoleSelect] = useState(NO_ROLE); const [customRole, setCustomRole] = useState(''); const [error, setError] = useState(null); + const draftKey = `addMember:${teamName}:workflow`; + const workflowDraft = useDraftPersistence({ + key: draftKey, + enabled: open, + }); + + // Pre-warm file list cache for @file mentions + useFileListCacheWarmer(open && projectPath ? projectPath : null); + + const mentionSuggestions = useMemo( + () => + existingMembers + .filter((m) => !m.removedAt) + .map((m) => ({ + id: m.name, + name: m.name, + subtitle: m.role ?? undefined, + color: m.color, + })), + [existingMembers] + ); + const effectiveRole = roleSelect === CUSTOM_ROLE ? customRole.trim() @@ -72,7 +100,9 @@ export const AddMemberDialog = ({ return; } setError(null); - onAdd(name.trim().toLowerCase(), effectiveRole); + const wf = workflowDraft.value.trim() || undefined; + onAdd(name.trim().toLowerCase(), effectiveRole, wf); + workflowDraft.clearDraft(); }; const handleOpenChange = (nextOpen: boolean): void => { @@ -80,11 +110,20 @@ export const AddMemberDialog = ({ setName(''); setRoleSelect(NO_ROLE); setCustomRole(''); + workflowDraft.setValue(''); + workflowDraft.clearDraft(); setError(null); onClose(); } }; + const handleWorkflowChange = useCallback( + (v: string) => { + workflowDraft.setValue(v); + }, + [workflowDraft] + ); + return ( @@ -113,27 +152,31 @@ export const AddMemberDialog = ({
- - {roleSelect === CUSTOM_ROLE && ( - setCustomRole(e.target.value)} - /> - )} + +
+ +
+ + Draft saved + ) : null + } + />
diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 8e5bbc09..91dcc6e1 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -89,28 +89,45 @@ export const CreateTaskDialog = ({ const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` }); const [blockedBySearch, setBlockedBySearch] = useState(''); const [relatedSearch, setRelatedSearch] = useState(''); - const [prevOpen, setPrevOpen] = useState(false); + const prevOpenRef = useRef(false); - if (open && !prevOpen) { - setSubject(defaultSubject); - if (defaultChip) { - const token = chipToken(defaultChip); - descriptionDraft.setValue(token + '\n'); - descChipDraft.setChips([defaultChip]); - } else if (defaultDescription) { - descriptionDraft.setValue(defaultDescription); + // 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); + descriptionDraft.setValue(token + '\n'); + descChipDraft.setChips([defaultChip]); + } else if (defaultDescription) { + descriptionDraft.setValue(defaultDescription); + descChipDraft.clearChipDraft(); + } else { + descriptionDraft.clearDraft(); + descChipDraft.clearChipDraft(); + } + setOwner(defaultOwner); + setBlockedBy([]); + setRelated([]); + setStartImmediately(defaultStartImmediately ?? isTeamAlive); + promptDraft.clearDraft(); + setBlockedBySearch(''); + setRelatedSearch(''); } - setOwner(defaultOwner); - setBlockedBy([]); - setRelated([]); - setStartImmediately(defaultStartImmediately ?? isTeamAlive); - promptDraft.clearDraft(); - setBlockedBySearch(''); - setRelatedSearch(''); - } - if (open !== prevOpen) { - setPrevOpen(open); - } + prevOpenRef.current = open; + }, [ + open, + defaultSubject, + defaultDescription, + defaultOwner, + defaultStartImmediately, + defaultChip, + isTeamAlive, + descriptionDraft, + descChipDraft, + promptDraft, + ]); const mentionSuggestions = useMemo( () => @@ -229,9 +246,16 @@ export const CreateTaskDialog = ({ {!isTeamAlive ? ( -
- -

+

+ +

Team is offline. The task will be added to TODO — launch the team to start execution.

diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 292194ae..0b7391d5 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -491,10 +491,11 @@ export const CreateTeamDialog = ({ activeError?.includes('Team already exists') === true && request.teamName.length > 0; const conflictingTeam = useMemo(() => { + if (!launchTeam) return null; if (!activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null; - }, [activeTeams, effectiveCwd]); + }, [activeTeams, effectiveCwd, launchTeam]); // Reset dismiss when conflict target changes useEffect(() => { @@ -554,6 +555,19 @@ export const CreateTeamDialog = ({ })(); }; + const handleTeamNameChange = (value: string): void => { + 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); + } + return rest; + }); + }; + return ( {conflictingTeam && !conflictDismissed ? ( -
+
- +
-

- Team “{conflictingTeam.displayName}” is already running in this - project +

+ Another team “{conflictingTeam.displayName}” is already running for + this working directory

-

+

Running two teams in the same directory is risky — they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.

+

+ Working directory: {effectiveCwd} +

+ } footerRight={ - textDraft.isSaved ? ( - Draft saved - ) : null +
+ {sendError ? ( + + + {sendError} + + ) : null} + {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {textDraft.isSaved ? ( + + Draft saved + + ) : null} +
} />
@@ -433,17 +453,12 @@ export const SendMessageDialog = ({ Shown as notification preview. Team lead also sees this for peer messages.

- - {sendError ?

{sendError}

: null}
-
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 1d68bcf2..39863d44 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +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 { ImagePlus, Loader2, Trash2, X } from 'lucide-react'; +import { isImageMimeType } from '@renderer/utils/attachmentUtils'; +import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react'; -import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types'; +import type { TaskAttachmentMeta } from '@shared/types'; const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); @@ -28,14 +30,21 @@ export const TaskAttachments = ({ const [uploading, setUploading] = useState(false); const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); - const [previewAttachment, setPreviewAttachment] = useState<{ - id: string; - mimeType: AttachmentMediaType; - dataUrl: string | null; - loading: boolean; - } | null>(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + const [thumbCache, setThumbCache] = useState>(new Map()); const fileInputRef = useRef(null); + const imageAttachments = attachments.filter((a) => isImageMimeType(a.mimeType)); + + const handleThumbLoaded = useCallback((attachmentId: string, dataUrl: string) => { + setThumbCache((prev) => { + if (prev.get(attachmentId) === dataUrl) return prev; + const next = new Map(prev); + next.set(attachmentId, dataUrl); + return next; + }); + }, []); + const handleFileSelect = useCallback( async (files: FileList | null) => { if (!files || files.length === 0) return; @@ -73,48 +82,74 @@ export const TaskAttachments = ({ ); const handleDelete = useCallback( - async (attachmentId: string, mimeType: AttachmentMediaType) => { + async (attachmentId: string, mimeType: string) => { setDeletingId(attachmentId); try { await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType); - if (previewAttachment?.id === attachmentId) { - setPreviewAttachment(null); - } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete'); } finally { setDeletingId(null); } }, - [teamName, taskId, deleteTaskAttachment, previewAttachment] + [teamName, taskId, deleteTaskAttachment] + ); + + const handleDownload = useCallback( + async (att: TaskAttachmentMeta) => { + setError(null); + try { + const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType); + if (!base64) { + setError('Attachment file not found'); + return; + } + 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); + const a = document.createElement('a'); + a.href = url; + a.download = att.filename || 'attachment'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to download'); + } + }, + [getTaskAttachmentData, teamName, taskId] + ); + + // 1x1 transparent PNG placeholder for slides where thumb is not yet loaded + const PLACEHOLDER_SRC = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg=='; + + const lightboxSlides = useMemo( + () => + imageAttachments.map((a) => ({ + src: thumbCache.get(a.id) ?? PLACEHOLDER_SRC, + alt: a.filename, + })), + [imageAttachments, thumbCache] ); const handlePreview = useCallback( - async (att: TaskAttachmentMeta) => { - if (previewAttachment?.id === att.id && previewAttachment.dataUrl) { - setPreviewAttachment(null); + (att: TaskAttachmentMeta) => { + if (!isImageMimeType(att.mimeType)) { + void handleDownload(att); return; } - setPreviewAttachment({ id: att.id, mimeType: att.mimeType, dataUrl: null, loading: true }); - try { - const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType); - if (base64) { - setPreviewAttachment({ - id: att.id, - mimeType: att.mimeType, - dataUrl: `data:${att.mimeType};base64,${base64}`, - loading: false, - }); - } else { - setPreviewAttachment(null); - setError('Attachment file not found'); - } - } catch { - setPreviewAttachment(null); - setError('Failed to load attachment'); + const idx = imageAttachments.findIndex((a) => a.id === att.id); + if (idx >= 0) { + setLightboxIndex(idx); } }, - [teamName, taskId, getTaskAttachmentData, previewAttachment] + [imageAttachments, handleDownload] ); // Handle paste events for quick image attachment @@ -179,38 +214,31 @@ export const TaskAttachments = ({ teamName={teamName} taskId={taskId} isDeleting={deletingId === att.id} - isPreviewActive={previewAttachment?.id === 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} /> ))}
) : null} - {/* Preview panel */} - {previewAttachment ? ( -
- - {previewAttachment.loading ? ( -
- - Loading image... -
- ) : previewAttachment.dataUrl ? ( - Attachment preview - ) : null} -
- ) : null} + {/* Image lightbox */} + {lightboxIndex !== null && ( + { + setLightboxIndex(null); + }} + slides={lightboxSlides} + index={lightboxIndex} + /> + )} {/* Drop zone indicator */} {dragOver ? ( @@ -256,9 +284,9 @@ interface AttachmentThumbnailProps { teamName: string; taskId: string; isDeleting: boolean; - isPreviewActive: boolean; onPreview: () => void; onDelete: () => void; + onDataLoaded?: (attachmentId: string, dataUrl: string) => void; } const AttachmentThumbnail = ({ @@ -266,9 +294,9 @@ const AttachmentThumbnail = ({ teamName, taskId, isDeleting, - isPreviewActive, onPreview, onDelete, + onDataLoaded, }: AttachmentThumbnailProps): React.JSX.Element => { const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); @@ -277,6 +305,7 @@ const AttachmentThumbnail = ({ let cancelled = false; void (async () => { try { + if (!isImageMimeType(attachment.mimeType)) return; const base64 = await getTaskAttachmentData( teamName, taskId, @@ -284,7 +313,9 @@ const AttachmentThumbnail = ({ attachment.mimeType ); if (!cancelled && base64) { - setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); + const dataUrl = `data:${attachment.mimeType};base64,${base64}`; + setThumbUrl(dataUrl); + onDataLoaded?.(attachment.id, dataUrl); } } catch { // ignore @@ -293,7 +324,7 @@ const AttachmentThumbnail = ({ return () => { cancelled = true; }; - }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); + }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]); const sizeLabel = attachment.size < 1024 @@ -304,17 +335,22 @@ const AttachmentThumbnail = ({ return (
- {thumbUrl ? ( - {attachment.filename} + {isImageMimeType(attachment.mimeType) ? ( + thumbUrl ? ( + {attachment.filename} + ) : ( + + ) ) : ( - +
+ +
+ {attachment.filename} +
+
)} {/* Delete button overlay */} + + Voice to text + - - Reply to comment - -
- {(() => { - const reply = parseMessageReply(comment.text); - const rawForDisplay = reply ? reply.replyText : comment.text; - const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); - const needsExpandCollapse = displayText.includes('\n'); - const expanded = expandedCommentIds.has(comment.id); - const collapsedHeight = 'max-h-[120px]'; - const showCollapsed = needsExpandCollapse && !expanded; - const showExpandedButton = needsExpandCollapse && expanded; - return ( -
-
+ {visibleComments.map((comment, index) => ( +
+ : undefined + } + > +
+ + {comment.type === 'review_approved' ? ( + + + Approved + + ) : comment.type === 'review_request' ? ( + + + Review requested + + ) : null} + + {(() => { + const date = new Date(comment.createdAt); + return isNaN(date.getTime()) + ? 'unknown time' + : formatDistanceToNow(date, { addSuffix: true }); + })()} + + + + + + Reply to comment + +
+ {(() => { + const reply = parseMessageReply(comment.text); + const rawForDisplay = reply ? reply.replyText : comment.text; + const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); + return ( + {reply ? ( ) : ( { - let t = displayText; - if (onTaskIdClick) t = linkifyTaskIdsInMarkdown(t); + let t = linkifyTaskIdsInMarkdown(displayText); if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); return t; })()} - maxHeight={ - needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none' - } + maxHeight="max-h-none" bare /> )} - {showCollapsed && ( - <> -
-
- -
- - )} -
- {showExpandedButton && ( -
- -
- )} -
- ); - })()} - {comment.attachments && comment.attachments.length > 0 ? ( - - ) : null} -
- ))} + + ); + })()} + {comment.attachments && comment.attachments.length > 0 ? ( + + ) : null} +
+ ))} +
{sortedComments.length > visibleComments.length ? (
@@ -378,22 +310,14 @@ export const TaskCommentsSection = ({
) : null} - {/* Full-size image preview overlay */} + {/* Image lightbox */} {previewImageUrl ? ( -
- - Attachment preview -
+ setPreviewImageUrl(null)} + src={previewImageUrl} + alt="Attachment preview" + /> ) : null} {!hideInput && ( @@ -427,10 +351,11 @@ export const TaskCommentsSection = ({
void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_COMMENT_LENGTH} @@ -487,11 +412,14 @@ const CommentAttachmentThumbnail = ({ }: CommentAttachmentThumbnailProps): React.JSX.Element => { const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); + const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); useEffect(() => { let cancelled = false; void (async () => { try { + if (!isImageMimeType(attachment.mimeType)) return; const base64 = await getTaskAttachmentData( teamName, taskId, @@ -511,19 +439,76 @@ const CommentAttachmentThumbnail = ({ }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); return ( -
thumbUrl && onPreview(thumbUrl)} - > - {thumbUrl ? ( - {attachment.filename} + + +
{ + if (isImageMimeType(attachment.mimeType)) { + if (thumbUrl) onPreview(thumbUrl); + return; + } + void (async () => { + setDownloading(true); + setDownloadError(null); + try { + const base64 = await getTaskAttachmentData( + teamName, + taskId, + attachment.id, + attachment.mimeType + ); + if (!base64) return; + const mime = + attachment.mimeType && typeof attachment.mimeType === 'string' + ? attachment.mimeType + : 'application/octet-stream'; + const dataUrl = `data:${mime};base64,${base64}`; + const blob = await fetch(dataUrl).then((r) => r.blob()); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = attachment.filename || 'attachment'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + setDownloadError(err instanceof Error ? err.message : 'Download failed'); + } finally { + setDownloading(false); + } + })(); + }} + > + {isImageMimeType(attachment.mimeType) ? ( + thumbUrl ? ( + {attachment.filename} + ) : ( + + ) + ) : downloading ? ( + + ) : ( + + )} +
+ {attachment.filename} +
+
+
+ {downloadError ? ( + + {downloadError} + ) : ( - + {attachment.filename} )} -
- {attachment.filename} -
-
+ ); }; @@ -556,7 +541,3 @@ const CommentAttachments = ({ ))}
); - -function teamIdKey(teamName: string, taskId: string): string { - return `${teamName}::${taskId}`; -} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 1845b8ed..27244c0f 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -14,20 +14,13 @@ import { DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; +import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Input } from '@renderer/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Textarea } from '@renderer/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; import { markAsRead } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; -import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap, KANBAN_COLUMN_DISPLAY, @@ -107,6 +100,7 @@ export const TaskDetailDialog = ({ const updateTaskFields = useStore((s) => s.updateTaskFields); const [logsRefreshing, setLogsRefreshing] = useState(false); + const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); // Inline editing: subject const [editingSubject, setEditingSubject] = useState(false); @@ -296,6 +290,12 @@ export const TaskDetailDialog = ({ .map((t) => t.id); const isTodo = status === 'pending' && !kanbanColumn; const canReassign = isTodo && onOwnerChange; + const leadName = + members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead'; + const isLeadOwnedTask = + (currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() || + (currentTask.owner ?? '').trim().toLowerCase() === 'team-lead'; + const allowLeadExecutionPreview = true; return ( !v && onClose()}> @@ -349,42 +349,14 @@ export const TaskDetailDialog = ({
{canReassign ? ( - + onOwnerChange(currentTask.id, v)} + allowUnassigned + size="sm" + className="min-w-[160px]" + /> ) : currentTask.owner ? ( ) : ( - + Unassigned )}
{currentTask.createdBy ? ( @@ -545,7 +517,7 @@ export const TaskDetailDialog = ({
{ if (e.key === 'Enter' || e.key === ' ') { @@ -554,7 +526,9 @@ export const TaskDetailDialog = ({ } }} > - + + + } headerExtra={ - logsRefreshing ? ( - - - Updating... + logsRefreshing || executionPreviewOnline ? ( + + {executionPreviewOnline ? ( + + + + + ) : null} + {logsRefreshing ? ( + + + Updating... + + ) : null} ) : null } @@ -700,6 +687,13 @@ export const TaskDetailDialog = ({ taskStatus={currentTask.status} taskWorkIntervals={currentTask.workIntervals} onRefreshingChange={setLogsRefreshing} + // Only show a "latest messages" preview when this task is owned by a subagent. + // For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents), + // so filtering to "just the member messages" is unreliable and easy to mislead. + showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask} + // Temporary debug option: for lead-owned tasks, show quick preview from lead session. + showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask} + onPreviewOnlineChange={setExecutionPreviewOnline} />
@@ -858,18 +852,20 @@ export const TaskDetailDialog = ({ ? (currentTask.comments?.length ?? 0) : undefined } - contentClassName="pl-2.5" + contentClassName="overflow-x-visible pl-0" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen > - +
+ +
handleDependencyClick(taskId) : undefined} + containerClassName="-mx-6" /> diff --git a/src/renderer/components/team/editor/EditorImagePreview.tsx b/src/renderer/components/team/editor/EditorImagePreview.tsx index eff4b230..44c74435 100644 --- a/src/renderer/components/team/editor/EditorImagePreview.tsx +++ b/src/renderer/components/team/editor/EditorImagePreview.tsx @@ -33,15 +33,15 @@ export const EditorImagePreview = ({ const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); const imgRef = useRef(null); - // Reset state when filePath changes (setState-during-render, React-approved pattern) - const [prevFilePath, setPrevFilePath] = useState(filePath); - if (prevFilePath !== filePath) { - setPrevFilePath(filePath); + // 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); setDimensions(null); - } + setLightboxOpen(false); + }, [filePath]); useEffect(() => { let cancelled = false; @@ -127,10 +127,10 @@ export const EditorImagePreview = ({
setLightboxOpen(false)} + src={dataUrl} + alt={fileName} />
); diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx index c2dd57d8..b823b767 100644 --- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx @@ -678,7 +678,14 @@ export const ProjectEditorOverlay = ({ {/* Draft recovery banner */} {draftRecoveredFile && activeTabId === draftRecoveredFile && ( -
+
Recovered unsaved changes from a previous session. - + { + e.preventDefault(); + setRecipientSearch(''); + setTimeout(() => recipientSearchRef.current?.focus(), 0); + }} + > + {members.length > 5 && ( +
+ + setRecipientSearch(e.target.value)} + /> +
+ )}
- {members.map((m) => { - const resolvedColor = colorMap.get(m.name); - const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - })} + + {role ? ( + + {role} + + ) : null} + {isSelected ? ( + + ) : null} + + ); + }); + })()}
@@ -308,7 +398,9 @@ export const MessageComposer = ({ ) : null} {!isTeamAlive ? ( - Team offline + + Team offline + ) : null}
@@ -316,11 +408,13 @@ export const MessageComposer = ({ attachments={attachments} onRemove={removeAttachment} error={attachmentError} + disabled={attachmentsBlocked} + disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." /> - - Send - +
+ {/* NOTE: ContextRing disabled — usage formula is inaccurate */} + + + + + Voice to text + + +
} footerRight={
+ + Mention "create a task" to add it to the board + {sendError ? ( diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 547193ca..1e834ad4 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -1,9 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Filter } from 'lucide-react'; import type { InboxMessage } from '@shared/types'; @@ -11,6 +14,8 @@ import type { InboxMessage } from '@shared/types'; export interface MessagesFilterState { from: Set; to: Set; + /** When true, include internal coordination noise (idle/shutdown/etc.) */ + showNoise: boolean; } interface MessagesFilterPopoverProps { @@ -44,18 +49,26 @@ export const MessagesFilterPopover = ({ onOpenChange, onApply, }: MessagesFilterPopoverProps): React.JSX.Element => { - const [draft, setDraft] = useState({ from: new Set(), to: new Set() }); + const [draft, setDraft] = useState({ + from: new Set(), + to: new Set(), + showNoise: false, + }); useEffect(() => { if (open) { const next = { from: new Set(filter.from), to: new Set(filter.to), + showNoise: !!filter.showNoise, }; const schedule = (): void => setDraft(next); queueMicrotask(schedule); } - }, [open, filter.from, filter.to]); + }, [open, filter.from, filter.to, filter.showNoise]); + + const members = useStore((s) => s.selectedTeamData?.members ?? []); + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const fromOptions = useMemo(() => collectFromOptions(messages), [messages]); const toOptions = useMemo(() => collectToOptions(messages), [messages]); @@ -87,7 +100,7 @@ export const MessagesFilterPopover = ({ }; const handleReset = (): void => { - const empty = { from: new Set(), to: new Set() }; + const empty = { from: new Set(), to: new Set(), showNoise: false }; setDraft(empty); onApply(empty); }; @@ -124,6 +137,7 @@ export const MessagesFilterPopover = ({

No data

) : ( fromOptions.map((name) => ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally )) )} @@ -147,23 +166,39 @@ export const MessagesFilterPopover = ({

No data

) : ( toOptions.map((name) => ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally )) )}
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */} + +
+
+ ) : null} + + {/* Sticky Show less */} + {expanded && needsTruncation ? ( +
+ +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx new file mode 100644 index 00000000..4d43f290 --- /dev/null +++ b/src/renderer/components/ui/MemberSelect.tsx @@ -0,0 +1,201 @@ +import * as React from 'react'; + +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { cn } from '@renderer/lib/utils'; +import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +import type { ResolvedTeamMember } from '@shared/types'; + +interface MemberSelectProps { + members: ResolvedTeamMember[]; + value: string | null; + onChange: (value: string | null) => void; + placeholder?: string; + /** Show "Unassigned" option at the top of the list */ + allowUnassigned?: boolean; + /** Size variant */ + size?: 'sm' | 'md'; + disabled?: boolean; + className?: string; +} + +const UNASSIGNED_VALUE = '__unassigned__'; + +export const MemberSelect = ({ + members, + value, + onChange, + placeholder = 'Select member...', + allowUnassigned = false, + size = 'sm', + disabled = false, + className, +}: MemberSelectProps): React.JSX.Element => { + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(''); + const listboxId = React.useId(); + + const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]); + const selectedMember = React.useMemo( + () => (value ? members.find((m) => m.name === value) : null), + [members, value] + ); + + const avatarSize = size === 'md' ? 32 : 24; + const avatarClass = size === 'md' ? 'size-6' : 'size-5'; + const textSize = size === 'md' ? 'text-xs' : 'text-[10px]'; + const triggerHeight = size === 'md' ? 'h-9' : 'h-8'; + + // eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure + const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => { + const resolvedColor = colorMap.get(member.name); + const colors = getTeamColorSet(resolvedColor ?? ''); + return ( + + + + {member.name === 'team-lead' ? 'lead' : member.name} + + + ); + }; + + return ( + + + + + + +
+ +
+ e.stopPropagation()} + > + + No members found. + + {allowUnassigned && !search.trim() ? ( + { + onChange(null); + setOpen(false); + setSearch(''); + }} + className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + > + Unassigned + {value === null ? ( + + ) : null} + + ) : null} + {members + .filter((m) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + m.name.toLowerCase().includes(q) || + (m.role?.toLowerCase().includes(q) ?? false) || + (m.agentType?.toLowerCase().includes(q) ?? false) + ); + }) + .map((m) => { + const isSelected = m.name === value; + const resolvedColor = colorMap.get(m.name); + const colors = getTeamColorSet(resolvedColor ?? ''); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + + return ( + { + onChange(m.name); + setOpen(false); + setSearch(''); + }} + className="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + > + + + {m.name === 'team-lead' ? 'lead' : m.name} + + {role ? ( + + {role} + + ) : null} + {isSelected ? ( + + ) : null} + + ); + })} + +
+
+
+ ); +}; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 2b304812..c51e7d7d 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -204,6 +204,8 @@ interface MentionableTextareaProps extends Omit< projectPath?: string | null; /** Called when a file chip is created via @ selection. Parent must add chip to state. */ onFileChipInsert?: (chip: InlineChip) => void; + /** Called when Enter (without Shift) is pressed. */ + onModEnter?: () => void; } export const MentionableTextarea = React.forwardRef( @@ -220,6 +222,7 @@ export const MentionableTextarea = React.forwardRef) => { + // Enter (without Shift) → submit; Shift+Enter → newline + if (e.key === 'Enter' && !e.shiftKey && onModEnter) { + e.preventDefault(); + onModEnter(); + return; + } handleChipKeyDown(e); if (!e.defaultPrevented) { if (enableFiles) { @@ -509,7 +518,7 @@ export const MentionableTextarea = React.forwardRef([]); const timerRef = useRef | null>(null); - const pendingRef = useRef(null); + const pendingRef = useRef<{ key: string; value: AttachmentPayload[] } | null>(null); const keyRef = useRef(persistenceKey); + // eslint-disable-next-line react-hooks/refs -- synchronous ref sync during render is intentional to avoid stale key in callbacks keyRef.current = persistenceKey; // Sync ref with state @@ -67,7 +68,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR const key = keyRef.current; if (!key) return; - pendingRef.current = nextAttachments; + pendingRef.current = { key, value: nextAttachments }; if (timerRef.current != null) { clearTimeout(timerRef.current); @@ -79,10 +80,10 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR pendingRef.current = null; if (pending == null) return; - if (pending.length === 0) { - void draftStorage.deleteDraft(key); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(key, JSON.stringify(pending)); + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)); } }, DEBOUNCE_MS); }, []); @@ -93,23 +94,34 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR timerRef.current = null; } if (pendingRef.current != null) { - const val = pendingRef.current; - const key = keyRef.current; + const pending = pendingRef.current; pendingRef.current = null; - if (!key) return; - if (val.length === 0) { - void draftStorage.deleteDraft(key); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(key, JSON.stringify(val)); + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)); } } }, []); // Load persisted attachments on mount useEffect(() => { - if (!persistenceKey) return; + if (!persistenceKey) { + // Transitioning to non-persistent context: flush pending save and clear stale state + flushPending(); + attachmentsRef.current = []; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset on key transition + setAttachments([]); + return; + } let cancelled = false; + // Flush any pending debounced save for the previous key before switching. + flushPending(); + // Clear stale attachments from previous persistenceKey before loading + attachmentsRef.current = []; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset before async load + setAttachments([]); void (async () => { const raw = await draftStorage.loadDraft(persistenceKey); if (cancelled || raw == null) return; @@ -133,7 +145,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR return () => { cancelled = true; }; - }, [persistenceKey]); + }, [persistenceKey, flushPending]); // Flush on unmount useEffect(() => { @@ -192,7 +204,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR schedulePersist(next); return next; }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable }, [schedulePersist] ); @@ -206,7 +217,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR return next; }); setError(null); - // eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable }, [schedulePersist] ); diff --git a/src/renderer/hooks/useChipDraftPersistence.ts b/src/renderer/hooks/useChipDraftPersistence.ts index e0c9b49b..d2732ed0 100644 --- a/src/renderer/hooks/useChipDraftPersistence.ts +++ b/src/renderer/hooks/useChipDraftPersistence.ts @@ -46,19 +46,50 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { const [chips, setChipsState] = useState([]); const [isSaved, setIsSaved] = useState(false); const timerRef = useRef | null>(null); - const pendingRef = useRef(null); + const pendingRef = useRef<{ key: string; value: InlineChip[] } | null>(null); const keyRef = useRef(key); - // Ref for current chips — allows addChip/removeChip to read latest value - // without stale closures, using the same sync-ref pattern as keyRef. - const chipsRef = useRef([]); useEffect(() => { keyRef.current = key; }, [key]); + // Ref for current chips — allows addChip/removeChip to read latest value + // without stale closures, using the same sync-ref pattern as keyRef. + const chipsRef = useRef([]); + const mountedRef = useRef(true); - // Load on mount + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current != null) { + const pending = pendingRef.current; + pendingRef.current = null; + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); + } else { + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)); + } + } + }, []); + + // Load on mount / key change useEffect(() => { let cancelled = false; + // Flush any pending debounced save for the previous key and reset local state for the new key. + flushPending(); + chipsRef.current = []; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset on key change before async load + setChipsState([]); + + setIsSaved(false); void (async () => { const raw = await draftStorage.loadDraft(key); if (cancelled || raw == null) return; @@ -76,23 +107,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { return () => { cancelled = true; }; - }, [key]); - - const flushPending = useCallback(() => { - if (timerRef.current != null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - if (pendingRef.current != null) { - const val = pendingRef.current; - pendingRef.current = null; - if (val.length === 0) { - void draftStorage.deleteDraft(keyRef.current); - } else { - void draftStorage.saveDraft(keyRef.current, JSON.stringify(val)); - } - } - }, []); + }, [key, flushPending]); // Flush on unmount useEffect(() => { @@ -105,7 +120,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { chipsRef.current = nextChips; setChipsState(nextChips); setIsSaved(false); - pendingRef.current = nextChips; + pendingRef.current = { key: keyRef.current, value: nextChips }; if (timerRef.current != null) { clearTimeout(timerRef.current); @@ -117,11 +132,11 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { pendingRef.current = null; if (pending == null) return; - if (pending.length === 0) { - void draftStorage.deleteDraft(keyRef.current); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(keyRef.current, JSON.stringify(pending)).then(() => { - setIsSaved(true); + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)).then(() => { + if (mountedRef.current) setIsSaved(true); }); } }, DEBOUNCE_MS); diff --git a/src/renderer/hooks/useDraftPersistence.ts b/src/renderer/hooks/useDraftPersistence.ts index fe6ecb03..8d6704d3 100644 --- a/src/renderer/hooks/useDraftPersistence.ts +++ b/src/renderer/hooks/useDraftPersistence.ts @@ -25,15 +25,52 @@ export function useDraftPersistence({ const [value, setValueState] = useState(initialValue ?? ''); const [isSaved, setIsSaved] = useState(false); const timerRef = useRef | null>(null); - const pendingValueRef = useRef(null); + const pendingValueRef = useRef<{ key: string; value: string } | null>(null); const keyRef = useRef(key); - keyRef.current = key; + const mountedRef = useRef(true); - // Load draft on mount useEffect(() => { - if (!enabled) return; + keyRef.current = key; + }, [key]); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingValueRef.current != null) { + const pending = pendingValueRef.current; + pendingValueRef.current = null; + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); + } else { + void draftStorage.saveDraft(pending.key, pending.value); + } + } + }, []); + + // Load draft on mount / key change + useEffect(() => { let cancelled = false; + // Prevent debounced saves for the previous key from landing under the new key. + flushPending(); + // Reset local state for the new key immediately. If a draft exists, it will overwrite below. + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset on key change before async load + setValueState(initialValue ?? ''); + + setIsSaved(false); + + if (!enabled) + return () => { + cancelled = true; + }; void (async () => { const draft = await draftStorage.loadDraft(key); if (cancelled) return; @@ -46,24 +83,7 @@ export function useDraftPersistence({ return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount - }, [key, enabled]); - - const flushPending = useCallback(() => { - if (timerRef.current != null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - if (pendingValueRef.current != null) { - const val = pendingValueRef.current; - pendingValueRef.current = null; - if (val.length === 0) { - void draftStorage.deleteDraft(keyRef.current); - } else { - void draftStorage.saveDraft(keyRef.current, val); - } - } - }, []); + }, [key, enabled, initialValue, flushPending]); // Flush on unmount useEffect(() => { @@ -79,7 +99,7 @@ export function useDraftPersistence({ if (!enabled) return; - pendingValueRef.current = v; + pendingValueRef.current = { key: keyRef.current, value: v }; if (timerRef.current != null) { clearTimeout(timerRef.current); @@ -91,11 +111,11 @@ export function useDraftPersistence({ pendingValueRef.current = null; if (pending == null) return; - if (pending.length === 0) { - void draftStorage.deleteDraft(keyRef.current); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(keyRef.current, pending).then(() => { - setIsSaved(true); + void draftStorage.saveDraft(pending.key, pending.value).then(() => { + if (mountedRef.current) setIsSaved(true); }); } }, debounceMs); diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 6db45f0b..9d359c87 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -5,7 +5,7 @@ * Returns up to 8 matching files filtered by name or relative path. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getQuickOpenCache, @@ -72,17 +72,16 @@ export function useFileSuggestions( // Bumped on cache invalidation (file create/delete) to trigger refetch const [fetchTrigger, setFetchTrigger] = useState(0); - // Re-seed from cache when projectPath changes (setState-during-render pattern) - const [prevPath, setPrevPath] = useState(projectPath); - if (prevPath !== projectPath) { - setPrevPath(projectPath); - const cached = projectPath ? getQuickOpenCache(projectPath) : null; - if (cached) { - setAllFiles(cached.files); - } else { + // Re-seed from cache when projectPath changes + useEffect(() => { + if (!projectPath) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync with prop change setAllFiles([]); + return; } - } + const cached = getQuickOpenCache(projectPath); + setAllFiles(cached?.files ?? []); + }, [projectPath]); // React to cache invalidation from EditorFileWatcher (create/delete events) useEffect(() => { @@ -90,13 +89,14 @@ export function useFileSuggestions( }, []); // Lazy refetch: when dropdown opens and cache is stale, trigger a reload - const [prevEnabled, setPrevEnabled] = useState(enabled); - if (enabled && !prevEnabled && projectPath && !getQuickOpenCache(projectPath)) { - setFetchTrigger((n) => n + 1); - } - if (prevEnabled !== enabled) { - setPrevEnabled(enabled); - } + const prevEnabledRef = useRef(enabled); + useEffect(() => { + if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional trigger on state transition + setFetchTrigger((n) => n + 1); + } + prevEnabledRef.current = enabled; + }, [enabled, projectPath]); // Load files from API when cache is empty. // Uses project:listFiles (not editor:listFiles) — works without editor being open. @@ -126,7 +126,7 @@ export function useFileSuggestions( // Fetch only when cache is empty. Cache seeding is handled by: // - lazy initializer (first mount) - // - setState-during-render (projectPath change) + // - effect (projectPath change) useEffect(() => { if (!projectPath) return; diff --git a/src/renderer/hooks/useResizableColumns.ts b/src/renderer/hooks/useResizableColumns.ts new file mode 100644 index 00000000..6ddf558a --- /dev/null +++ b/src/renderer/hooks/useResizableColumns.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const STORAGE_PREFIX = 'kanban-column-widths:'; +const MIN_COLUMN_WIDTH = 180; +const DEFAULT_COLUMN_WIDTH = 256; // w-64 + +interface UseResizableColumnsOptions { + /** Storage key suffix (e.g. teamName). */ + storageKey: string; + /** Column IDs in display order. */ + columnIds: string[]; +} + +interface UseResizableColumnsResult { + /** Width in px for each column ID. */ + widths: Map; + /** Props to spread on the drag handle between columns. */ + getHandleProps: (leftColumnId: string) => { + onPointerDown: (e: React.PointerEvent) => void; + style: React.CSSProperties; + 'aria-label': string; + }; +} + +function loadWidths(key: string): Record { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + key); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + const result: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'number' && v >= MIN_COLUMN_WIDTH) { + result[k] = v; + } + } + return result; + } catch { + return {}; + } +} + +function saveWidths(key: string, widths: Record): void { + try { + localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(widths)); + } catch { + // Quota exceeded — ignore + } +} + +export function useResizableColumns({ + storageKey, + columnIds, +}: UseResizableColumnsOptions): UseResizableColumnsResult { + const [widthRecord, setWidthRecord] = useState>(() => + loadWidths(storageKey) + ); + + // Re-read from localStorage when storageKey changes + useEffect(() => { + setWidthRecord(loadWidths(storageKey)); + }, [storageKey]); + + const draggingRef = useRef<{ + leftId: string; + startX: number; + startWidth: number; + } | null>(null); + const abortRef = useRef(null); + + const widths = new Map(); + for (const id of columnIds) { + widths.set(id, widthRecord[id] ?? DEFAULT_COLUMN_WIDTH); + } + + const handlePointerMove = useCallback((e: PointerEvent) => { + const drag = draggingRef.current; + if (!drag) return; + const delta = e.clientX - drag.startX; + const newWidth = Math.max(MIN_COLUMN_WIDTH, drag.startWidth + delta); + setWidthRecord((prev) => ({ ...prev, [drag.leftId]: newWidth })); + }, []); + + const handlePointerUp = useCallback(() => { + const drag = draggingRef.current; + if (!drag) return; + draggingRef.current = null; + abortRef.current?.abort(); + abortRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + // Persist + setWidthRecord((current) => { + saveWidths(storageKey, current); + return current; + }); + }, [storageKey]); + + // Safety: if the board unmounts or storageKey changes mid-drag, clean up global listeners/styles. + useEffect(() => { + return () => { + draggingRef.current = null; + abortRef.current?.abort(); + abortRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, []); + + const getHandleProps = useCallback( + (leftColumnId: string) => ({ + onPointerDown: (e: React.PointerEvent) => { + e.preventDefault(); + const currentWidth = widthRecord[leftColumnId] ?? DEFAULT_COLUMN_WIDTH; + draggingRef.current = { + leftId: leftColumnId, + startX: e.clientX, + startWidth: currentWidth, + }; + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + document.addEventListener('pointermove', handlePointerMove, { signal: ac.signal }); + document.addEventListener('pointerup', handlePointerUp, { signal: ac.signal }); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + style: { + cursor: 'col-resize' as const, + width: 8, + flexShrink: 0, + alignSelf: 'stretch' as const, + }, + 'aria-label': `Resize column ${leftColumnId}`, + }), + [widthRecord, handlePointerMove, handlePointerUp] + ); + + return { widths, getHandleProps }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 60d9387e..563130fa 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -182,6 +182,11 @@ --card-text-lighter: #e2e8f0; --card-separator: #2a2c38; + /* System activity messages */ + --system-activity-bg: rgba(59, 130, 246, 0.06); + --system-activity-border: rgba(59, 130, 246, 0.12); + --system-activity-accent: rgba(96, 165, 250, 0.5); + /* Assessment severity colors (badges, health indicators) */ --assess-good: #4ade80; --assess-warning: #fbbf24; @@ -198,6 +203,20 @@ --skeleton-base: #1a1c28; --skeleton-base-light: #23252f; --skeleton-base-dim: rgba(26, 28, 40, 0.6); + + /* Provisioning step badges */ + --step-done-bg: rgba(16, 185, 129, 0.1); + --step-done-border: rgba(52, 211, 153, 0.6); + --step-done-text: #6ee7b7; + --step-current-bg: rgba(99, 102, 241, 0.15); + --step-current-border: rgba(129, 140, 248, 0.7); + --step-current-text: #f1f5f9; + --step-error-text: #fca5a5; + --step-error-text-dim: rgba(252, 165, 165, 0.8); + --step-success-text: #6ee7b7; + --step-warning-text: #fde68a; + --step-warning-border: rgba(245, 158, 11, 0.4); + --step-warning-bg: rgba(245, 158, 11, 0.1); } /* File icon glow — halo so dark icons stay visible on dark backgrounds */ @@ -390,6 +409,11 @@ --card-text-lighter: #2a2925; --card-separator: #d5d3cf; + /* System activity messages */ + --system-activity-bg: rgba(59, 130, 246, 0.06); + --system-activity-border: rgba(59, 130, 246, 0.15); + --system-activity-accent: rgba(37, 99, 235, 0.5); + /* Sticky Context button - transparent glass */ --context-btn-bg: rgba(0, 0, 0, 0.06); --context-btn-bg-hover: rgba(0, 0, 0, 0.1); @@ -400,6 +424,20 @@ --skeleton-base: #d6d8de; --skeleton-base-light: #cdd0d7; --skeleton-base-dim: rgba(205, 208, 215, 0.6); + + /* Provisioning step badges — dark enough for light backgrounds */ + --step-done-bg: rgba(16, 185, 129, 0.12); + --step-done-border: rgba(5, 150, 105, 0.5); + --step-done-text: #047857; + --step-current-bg: rgba(79, 70, 229, 0.1); + --step-current-border: rgba(79, 70, 229, 0.5); + --step-current-text: #1c1b19; + --step-error-text: #dc2626; + --step-error-text-dim: rgba(220, 38, 38, 0.7); + --step-success-text: #047857; + --step-warning-text: #b45309; + --step-warning-border: rgba(180, 83, 9, 0.4); + --step-warning-bg: rgba(245, 158, 11, 0.1); } /* rehype-highlight (highlight.js) — map hljs classes to app theme variables */ diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 0829db2e..7ddff600 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -370,9 +370,7 @@ export function initializeNotificationListeners(): () => void { // Clear context data when lead goes offline if (nextActivity === 'offline') { nextState.leadContextByTeam = { ...prev.leadContextByTeam }; - delete (nextState.leadContextByTeam as Record)[ - event.teamName - ]; + delete nextState.leadContextByTeam[event.teamName]; } return nextState as typeof prev; diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index ddba4009..214025d0 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -120,7 +120,12 @@ export interface SessionDetailSlice { tabSessionData: Record; // Actions - fetchSessionDetail: (projectId: string, sessionId: string, tabId?: string) => Promise; + fetchSessionDetail: ( + projectId: string, + sessionId: string, + tabId?: string, + options?: { silent?: boolean } + ) => Promise; /** Refresh session without loading states or UI resets - for real-time updates */ refreshSessionInPlace: (projectId: string, sessionId: string) => Promise; setVisibleAIGroup: (aiGroupId: string | null) => void; @@ -162,16 +167,23 @@ export const createSessionDetailSlice: StateCreator { + fetchSessionDetail: async ( + projectId: string, + sessionId: string, + tabId?: string, + options?: { silent?: boolean } + ) => { const requestGeneration = incrementTabGeneration(tabId); - set({ - sessionDetailLoading: true, - sessionDetailError: null, - conversationLoading: true, - }); + if (!options?.silent) { + set({ + sessionDetailLoading: true, + sessionDetailError: null, + conversationLoading: true, + }); + } // Also set per-tab loading state - if (tabId) { + if (tabId && !options?.silent) { const prev = get().tabSessionData; set({ tabSessionData: { @@ -461,10 +473,12 @@ export const createSessionDetailSlice: StateCreator Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; @@ -549,7 +550,9 @@ export const createTeamSlice: StateCreator = (set, const state = get(); // Use display name from teams list or selected team data if available const teamSummary = state.teamByName[teamName]; - const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName; + const selectedTeamDisplayName = + state.selectedTeamName === teamName ? state.selectedTeamData?.config.name : undefined; + const displayName = teamSummary?.displayName || selectedTeamDisplayName || teamName; const allTabs = state.getAllPaneTabs(); const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName); diff --git a/src/renderer/store/slices/updateSlice.ts b/src/renderer/store/slices/updateSlice.ts index aa0852c8..5490acc6 100644 --- a/src/renderer/store/slices/updateSlice.ts +++ b/src/renderer/store/slices/updateSlice.ts @@ -57,6 +57,7 @@ export const createUpdateSlice: StateCreator = (s set({ updateStatus: 'checking', updateError: null }); api.updater.check().catch((error) => { logger.error('Failed to check for updates:', error); + set({ updateStatus: 'error', updateError: error instanceof Error ? error.message : 'Check failed' }); }); }, diff --git a/src/renderer/utils/agentMessageFormatting.ts b/src/renderer/utils/agentMessageFormatting.ts index ab41fb17..f194bc96 100644 --- a/src/renderer/utils/agentMessageFormatting.ts +++ b/src/renderer/utils/agentMessageFormatting.ts @@ -15,9 +15,19 @@ export interface ParsedMessageReply { const REPLY_BLOCK_RE = new RegExp( '```' + MESSAGE_REPLY_TAG + - '\\nReply on @([\\w-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' + '\\nReply on @([\\w.-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' ); +function encodeReplyField(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function decodeReplyField(value: string): string { + // Backwards-compat: avoid touching legacy content that has no escapes. + if (!value.includes('\\"') && !value.includes('\\\\')) return value; + return value.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); +} + /** * Parses a message_reply_for_agent block from content. * Returns null if no reply block is found. @@ -27,8 +37,8 @@ export function parseMessageReply(content: string): ParsedMessageReply | null { if (!match) return null; return { agentName: match[1], - originalText: match[2], - replyText: match[3], + originalText: decodeReplyField(match[2]), + replyText: decodeReplyField(match[3]), }; } @@ -43,7 +53,7 @@ export function buildReplyBlock( const tag = MESSAGE_REPLY_TAG; return [ '```' + tag, - `Reply on @${agentName} original message with text "${originalText}", here is answer: "${replyText}"`, + `Reply on @${agentName} original message with text "${encodeReplyField(originalText)}", here is answer: "${encodeReplyField(replyText)}"`, '```', ].join('\n'); } diff --git a/src/renderer/utils/attachmentUtils.ts b/src/renderer/utils/attachmentUtils.ts index c88e5f46..c149a557 100644 --- a/src/renderer/utils/attachmentUtils.ts +++ b/src/renderer/utils/attachmentUtils.ts @@ -1,6 +1,6 @@ -import type { AttachmentMediaType, AttachmentPayload } from '@shared/types'; +import type { AttachmentPayload, ImageMimeType } from '@shared/types'; -export const ALLOWED_MIME_TYPES = new Set([ +export const ALLOWED_MIME_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/gif', @@ -11,8 +11,8 @@ export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export const MAX_FILES = 5; export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB -export function isImageMimeType(type: string): type is AttachmentMediaType { - return ALLOWED_MIME_TYPES.has(type as AttachmentMediaType); +export function isImageMimeType(type: string): type is ImageMimeType { + return ALLOWED_MIME_TYPES.has(type as ImageMimeType); } export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } { @@ -35,7 +35,7 @@ export async function fileToAttachmentPayload(file: File): Promise; + if (obj.type !== 'assistant') return null; + + // Direct format can include id at top-level + if (typeof obj.id === 'string' && obj.id.trim()) return obj.id.trim(); + + // Wrapped format: { type: "assistant", message: { id, ... } } + const msg = obj.message; + if (msg && typeof msg === 'object') { + const inner = msg as Record; + if (typeof inner.id === 'string' && inner.id.trim()) return inner.id.trim(); + } + + return null; +} + +/** + * Module-level timestamp cache keyed by line content. + * Ensures re-parses of the same log lines preserve their original timestamps + * instead of getting new Date() each time. + */ +const lineTimestampCache = new Map(); +const MAX_TIMESTAMP_CACHE_SIZE = 5000; + /** * Parses stream-json CLI output lines into structured groups for rich rendering. * @@ -155,28 +181,32 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] const groups: StreamJsonGroup[] = []; let currentItems: AIGroupDisplayItem[] = []; let currentTimestamp: Date | null = null; - let groupCounter = 0; - // Stable timestamp for the entire parse (deterministic across re-renders) - const parseTimestamp = new Date(); + let currentGroupId: string | null = null; + // Track how many times each messageId has been seen to disambiguate duplicates + const msgIdOccurrences = new Map(); const flushGroup = (): void => { if (currentItems.length > 0 && currentTimestamp) { + const id = currentGroupId ?? `stream-group-fallback-${groups.length}`; groups.push({ - id: `stream-group-${groupCounter++}`, + id, items: currentItems, summary: buildGroupSummary(currentItems), timestamp: currentTimestamp, }); currentItems = []; currentTimestamp = null; + currentGroupId = null; } }; for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const trimmed = lines[lineIndex].trim(); - // Skip empty lines and stream markers - if (!trimmed || trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) { + // Skip empty lines; stream markers break groups + if (!trimmed) continue; + if (trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) { + flushGroup(); continue; } @@ -197,9 +227,33 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] continue; } - if (!currentTimestamp) currentTimestamp = parseTimestamp; + if (!currentTimestamp) { + // Use stable cached timestamp keyed by line content to survive re-parses + let ts = lineTimestampCache.get(trimmed); + if (!ts) { + ts = new Date(); + if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) { + // Evict oldest entry (first inserted) + const firstKey = lineTimestampCache.keys().next().value!; + lineTimestampCache.delete(firstKey); + } + lineTimestampCache.set(trimmed, ts); + } + currentTimestamp = ts; + } + if (!currentGroupId) { + const msgId = extractAssistantMessageId(parsed); + if (msgId) { + const occurrence = msgIdOccurrences.get(msgId) ?? 0; + msgIdOccurrences.set(msgId, occurrence + 1); + currentGroupId = + occurrence === 0 ? `stream-group-${msgId}` : `stream-group-${msgId}-${occurrence}`; + } else { + currentGroupId = `stream-group-L${lineIndex}`; + } + } - const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex); + const items = contentBlocksToDisplayItems(blocks, currentTimestamp, lineIndex); currentItems.push(...items); } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 46fb4e87..d8227f7a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -30,7 +30,6 @@ import type { import type { AddMemberRequest, AttachmentFileData, - AttachmentMediaType, CommentAttachmentPayload, CreateTaskRequest, GlobalTask, @@ -45,6 +44,8 @@ import type { TaskAttachmentMeta, TaskComment, TeamChangeEvent, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, @@ -398,6 +399,7 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; getData: (teamName: string) => Promise; + getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise; deleteTeam: (teamName: string) => Promise; restoreTeam: (teamName: string) => Promise; permanentlyDeleteTeam: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f67c6c9c..06220230 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -84,7 +84,7 @@ export interface TaskComment { text: string; createdAt: string; type: TaskCommentType; - /** Image attachments on this comment. Metadata only — files stored on disk. */ + /** Attachments on this comment. Metadata only — files stored on disk. */ attachments?: TaskAttachmentMeta[]; } @@ -125,7 +125,7 @@ export interface TeamTask { needsClarification?: 'lead' | 'user'; /** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */ deletedAt?: string; - /** Image attachments associated with this task. Metadata only — actual files stored on disk. */ + /** Attachments associated with this task. Metadata only — actual files stored on disk. */ attachments?: TaskAttachmentMeta[]; } @@ -135,7 +135,7 @@ export interface TeamTaskWithKanban extends TeamTask { kanbanColumn?: 'review' | 'approved'; } -/** Metadata for an image attached to a task description. */ +/** Metadata for an attachment associated with a task or comment. */ export interface TaskAttachmentMeta { /** Unique attachment ID (uuid). */ id: string; @@ -157,7 +157,17 @@ export interface CommentAttachmentPayload { base64Data: string; } -export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; +/** + * Broad MIME type string (e.g. "image/png", "application/pdf"). + * + * Note: the UI may still choose to preview only certain types (e.g. images), + * but tasks/comments can store arbitrary attachments for agent workflows. + */ +// eslint-disable-next-line sonarjs/redundant-type-aliases -- semantic alias for documentation/readability +export type AttachmentMediaType = string; + +/** Supported image MIME types (used for preview/validation in UI). */ +export type ImageMimeType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; export interface AttachmentMeta { id: string; @@ -185,8 +195,10 @@ export interface InboxMessage { summary?: string; color?: string; messageId?: string; - source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent'; + source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent' | 'system_notification'; attachments?: AttachmentMeta[]; + /** Lead session ID that produced this message (for session boundary detection). */ + leadSessionId?: string; } export interface SendMessageRequest { @@ -195,6 +207,7 @@ export interface SendMessageRequest { summary?: string; from?: string; attachments?: AttachmentPayload[]; + source?: InboxMessage['source']; } export interface SendMessageResult { @@ -311,6 +324,24 @@ export interface TeamChangeEvent { detail?: string; } +export interface TeamClaudeLogsQuery { + /** Offset in lines from the newest log line (0 = newest). */ + offset?: number; + /** Max number of lines to return. */ + limit?: number; +} + +export interface TeamClaudeLogsResponse { + /** Log lines ordered newest-first. */ + lines: string[]; + /** Total number of buffered lines available in memory. */ + total: number; + /** True when there are older lines beyond the current window. */ + hasMore: boolean; + /** ISO timestamp of the last observed CLI output for this team. */ + updatedAt?: string; +} + export type TeamProvisioningState = | 'idle' | 'validating' @@ -444,6 +475,7 @@ export interface MemberFullStats { export interface AddMemberRequest { name: string; role?: string; + workflow?: string; } export interface RemoveMemberRequest { diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts new file mode 100644 index 00000000..10f3a270 --- /dev/null +++ b/src/shared/utils/inboxNoise.ts @@ -0,0 +1,38 @@ +/** + * Inbox "noise" messages are structured JSON objects that represent internal coordination + * signals (idle/shutdown/etc.). They should not trigger user-facing notifications or + * automatic lead relays. + */ +export const INBOX_NOISE_TYPES = [ + 'idle_notification', + 'shutdown_approved', + 'teammate_terminated', + 'shutdown_request', +] as const; + +const INBOX_NOISE_SET = new Set(INBOX_NOISE_TYPES); + +export function parseInboxJson(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed.startsWith('{')) return null; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // not JSON + } + return null; +} + +export function getInboxJsonType(text: string): string | null { + const parsed = parseInboxJson(text); + if (!parsed) return null; + return typeof parsed.type === 'string' ? parsed.type : null; +} + +export function isInboxNoiseMessage(text: string): boolean { + const type = getInboxJsonType(text); + return !!type && INBOX_NOISE_SET.has(type); +} diff --git a/src/shared/utils/teamMemberName.ts b/src/shared/utils/teamMemberName.ts new file mode 100644 index 00000000..b2bc2aca --- /dev/null +++ b/src/shared/utils/teamMemberName.ts @@ -0,0 +1,40 @@ +export function parseNumericSuffixName(name: string): { base: string; suffix: number } | null { + const trimmed = name.trim(); + if (!trimmed) return null; + const match = /^(.+)-(\d+)$/.exec(trimmed); + if (!match?.[1] || !match[2]) return null; + const suffix = Number(match[2]); + if (!Number.isFinite(suffix)) return null; + return { base: match[1], suffix }; +} + +/** + * Claude CLI auto-suffixes teammate names when a name already exists in config.json + * (e.g. "alice" → "alice-2"). We treat "-2+" as an auto-suffix only when the base + * name also exists among the current set of names. + * + * Important: do NOT treat "-1" as auto-suffix; it's commonly intentional ("dev-1"). + */ +export function createCliAutoSuffixNameGuard( + allNames: Iterable +): (name: string) => boolean { + const trimmed: string[] = []; + const seen = new Set(); + for (const n of allNames) { + if (typeof n !== 'string') continue; + const t = n.trim(); + if (!t) continue; + if (seen.has(t)) continue; + seen.add(t); + trimmed.push(t); + } + + const allLower = new Set(trimmed.map((n) => n.toLowerCase())); + + return (name: string): boolean => { + const info = parseNumericSuffixName(name); + if (!info) return true; + if (info.suffix < 2) return true; + return !allLower.has(info.base.toLowerCase()); + }; +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index c1227e30..522dd469 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -8,57 +8,11 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => []) }, })); -vi.mock('@preload/constants/ipcChannels', () => ({ - TEAM_LIST: 'team:list', - TEAM_GET_DATA: 'team:getData', - TEAM_DELETE_TEAM: 'team:deleteTeam', - TEAM_PREPARE_PROVISIONING: 'team:prepareProvisioning', - TEAM_CREATE: 'team:create', - TEAM_LAUNCH: 'team:launch', - TEAM_CREATE_CONFIG: 'team:createConfig', - TEAM_CREATE_TASK: 'team:createTask', - TEAM_PROVISIONING_STATUS: 'team:provisioningStatus', - TEAM_CANCEL_PROVISIONING: 'team:cancelProvisioning', - TEAM_PROVISIONING_PROGRESS: 'team:provisioningProgress', - TEAM_SEND_MESSAGE: 'team:sendMessage', - TEAM_REQUEST_REVIEW: 'team:requestReview', - TEAM_UPDATE_KANBAN: 'team:updateKanban', - TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder', - TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus', - TEAM_UPDATE_TASK_FIELDS: 'team:updateTaskFields', - TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner', - TEAM_PROCESS_SEND: 'team:processSend', - TEAM_PROCESS_ALIVE: 'team:processAlive', - TEAM_ALIVE_LIST: 'team:aliveList', - TEAM_STOP: 'team:stop', - TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs', - TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask', - TEAM_GET_MEMBER_STATS: 'team:getMemberStats', - TEAM_UPDATE_CONFIG: 'team:updateConfig', - TEAM_START_TASK: 'team:startTask', - TEAM_GET_ALL_TASKS: 'team:getAllTasks', - TEAM_ADD_TASK_COMMENT: 'team:addTaskComment', - TEAM_ADD_MEMBER: 'team:addMember', - TEAM_REPLACE_MEMBERS: 'team:replaceMembers', - TEAM_REMOVE_MEMBER: 'team:removeMember', - TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole', - TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch', - TEAM_GET_ATTACHMENTS: 'team:getAttachments', - TEAM_KILL_PROCESS: 'team:killProcess', - TEAM_LEAD_ACTIVITY: 'team:leadActivity', - TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', - TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', - TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification', - TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification', - TEAM_ADD_TASK_RELATIONSHIP: 'team:addTaskRelationship', - TEAM_REMOVE_TASK_RELATIONSHIP: 'team:removeTaskRelationship', - TEAM_RESTORE: 'team:restoreTeam', - TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam', - TEAM_RESTORE_TASK: 'team:restoreTask', - TEAM_SAVE_TASK_ATTACHMENT: 'team:saveTaskAttachment', - TEAM_GET_TASK_ATTACHMENT: 'team:getTaskAttachment', - TEAM_DELETE_TASK_ATTACHMENT: 'team:deleteTaskAttachment', -})); +// Keep this mock resilient to new exports (avoid drift). +vi.mock('@preload/constants/ipcChannels', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); import { TEAM_ALIVE_LIST, @@ -102,6 +56,14 @@ import { TEAM_ADD_TASK_RELATIONSHIP, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, + TEAM_UPDATE_TASK_OWNER, + TEAM_UPDATE_TASK_FIELDS, + TEAM_LEAD_CONTEXT, + TEAM_RESTORE_TASK, + TEAM_SHOW_MESSAGE_NOTIFICATION, + TEAM_SAVE_TASK_ATTACHMENT, + TEAM_GET_TASK_ATTACHMENT, + TEAM_DELETE_TASK_ATTACHMENT, } from '../../../src/preload/constants/ipcChannels'; import { initializeTeamHandlers, @@ -231,6 +193,17 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(true); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(true); + expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(true); + expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(true); + expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(true); + expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(true); + expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(true); + expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(true); + expect(handlers.has(TEAM_RESTORE_TASK)).toBe(true); + expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(true); + expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(true); + expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true); }); it('returns success false on invalid sendMessage args', async () => { @@ -552,6 +525,15 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(false); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(false); + expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(false); + expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(false); + expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(false); + expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(false); + expect(handlers.has(TEAM_RESTORE_TASK)).toBe(false); + expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(false); + expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(false); + expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(false); + expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false); }); describe('addTaskRelationship', () => { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 4fdfa0be..b69b798b 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; -import type { TeamTask } from '../../../../src/shared/types/team'; +import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team'; describe('TeamDataService', () => { it('runs kanban garbage-collect only after tasks are loaded', async () => { @@ -47,6 +47,72 @@ describe('TeamDataService', () => { expect(order).toEqual(['tasks', 'gc']); }); + it('does not sync automated comment notifications into task comments', async () => { + const tasks: TeamTask[] = [ + { + id: '12', + subject: 'Task', + status: 'pending', + }, + ]; + + const addComment = vi.fn(async () => { + throw new Error('Should not be called'); + }); + + const messages: InboxMessage[] = [ + { + from: 'team-lead', + to: 'alice', + summary: 'Comment on #12', + messageId: 'm1', + timestamp: new Date().toISOString(), + read: false, + text: + 'Comment on task #12 "Task":\n\nHello\n\n' + + '\n' + + 'Reply to this comment using:\n' + + 'node "tool.js" --team my-team task comment 12 --text "..." --from "alice"\n' + + '', + }, + ]; + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })), + } as never, + { + getTasks: vi.fn(async () => tasks), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => messages), + } as never, + {} as never, + { + addComment, + } as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + garbageCollect: vi.fn(async () => undefined), + } as never, + {} as never, + { + readMembers: vi.fn(async () => []), + } as never, + { + readMessages: vi.fn(async () => []), + } as never + ); + + await service.getTeamData('my-team'); + expect(addComment).not.toHaveBeenCalled(); + }); + it('skips kanban garbage-collect when tasks fail to load', async () => { const garbageCollect = vi.fn(async () => undefined); const service = new TeamDataService( diff --git a/test/main/services/team/TeamInboxWriter.test.ts b/test/main/services/team/TeamInboxWriter.test.ts index 5bf0efef..fd44cfd4 100644 --- a/test/main/services/team/TeamInboxWriter.test.ts +++ b/test/main/services/team/TeamInboxWriter.test.ts @@ -130,4 +130,28 @@ describe('TeamInboxWriter', () => { expect(persisted).toHaveLength(2); expect(persisted.map((row) => row.text).sort()).toEqual(['first', 'second']); }); + + it('includes source field in payload when provided in request', async () => { + await writer.sendMessage('my-team', { + member: 'alice', + text: 'task assigned', + summary: 'New task #1 assigned', + source: 'system_notification', + }); + + const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record[]; + expect(persisted).toHaveLength(1); + expect(persisted[0].source).toBe('system_notification'); + }); + + it('omits source field from payload when not provided in request', async () => { + await writer.sendMessage('my-team', { + member: 'alice', + text: 'hello', + }); + + const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record[]; + expect(persisted).toHaveLength(1); + expect(persisted[0]).not.toHaveProperty('source'); + }); }); diff --git a/test/main/services/team/teamctl.test.ts b/test/main/services/team/teamctl.test.ts index ec5095df..01c3b083 100644 --- a/test/main/services/team/teamctl.test.ts +++ b/test/main/services/team/teamctl.test.ts @@ -782,6 +782,161 @@ describe('teamctl.js', () => { }); }); + // ========================================================================= + // Attachments (task + comment) + // ========================================================================= + describe('attachments', () => { + it('task attach copies file into storage and records metadata', () => { + // Create task + expect(run(claudeDir, ['task', 'create', '--subject', 'With attachment']).exitCode).toBe(0); + + const samplePath = path.join(claudeDir, 'sample.txt'); + fs.writeFileSync(samplePath, 'hello'); + + const { stdout, exitCode } = run(claudeDir, ['task', 'attach', '1', '--file', samplePath]); + expect(exitCode).toBe(0); + + const meta = JSON.parse(stdout) as { + id: string; + filename: string; + mimeType: string; + size: number; + addedAt: string; + }; + expect(meta.id).toBeDefined(); + expect(meta.filename).toBe('sample.txt'); + expect(meta.mimeType).toBe('text/plain'); + expect(meta.size).toBe(5); + expect(meta.addedAt).toMatch(ISO_RE); + + const storedPath = path.join( + claudeDir, + 'teams', + TEAM, + 'task-attachments', + '1', + `${meta.id}--${meta.filename}` + ); + expect(fs.existsSync(storedPath)).toBe(true); + expect(fs.readFileSync(storedPath, 'utf8')).toBe('hello'); + + const task = readTask(claudeDir, '1'); + const attachments = task.attachments as Record[]; + expect(attachments).toHaveLength(1); + expect(attachments[0].id).toBe(meta.id); + expect(attachments[0].filename).toBe(meta.filename); + expect(attachments[0].mimeType).toBe(meta.mimeType); + }); + + it('task attach supports --filename and --mime-type overrides', () => { + expect(run(claudeDir, ['task', 'create', '--subject', 'With override']).exitCode).toBe(0); + + const samplePath = path.join(claudeDir, 'sample.bin'); + fs.writeFileSync(samplePath, Buffer.from([1, 2, 3, 4])); + + const { stdout, exitCode } = run(claudeDir, [ + 'task', + 'attach', + '1', + '--file', + samplePath, + '--filename', + 'renamed.dat', + '--mime-type', + 'application/octet-stream', + ]); + expect(exitCode).toBe(0); + const meta = JSON.parse(stdout) as { id: string; filename: string; mimeType: string; size: number }; + expect(meta.filename).toBe('renamed.dat'); + expect(meta.mimeType).toBe('application/octet-stream'); + + const storedPath = path.join( + claudeDir, + 'teams', + TEAM, + 'task-attachments', + '1', + `${meta.id}--${meta.filename}` + ); + expect(fs.existsSync(storedPath)).toBe(true); + expect(fs.readFileSync(storedPath)).toEqual(Buffer.from([1, 2, 3, 4])); + }); + + it('task comment-attach adds attachment to a specific comment', () => { + expect(run(claudeDir, ['task', 'create', '--subject', 'Comment attach']).exitCode).toBe(0); + expect(run(claudeDir, ['task', 'comment', '1', '--text', 'First comment', '--from', 'alice']).exitCode).toBe( + 0 + ); + + const taskAfterComment = readTask(claudeDir, '1'); + const commentId = String((taskAfterComment.comments as Record[])[0].id); + + const samplePath = path.join(claudeDir, 'comment.txt'); + fs.writeFileSync(samplePath, 'comment-file'); + + const { stdout, exitCode } = run(claudeDir, [ + 'task', + 'comment-attach', + '1', + commentId, + '--file', + samplePath, + ]); + expect(exitCode).toBe(0); + const meta = JSON.parse(stdout) as { id: string; filename: string; mimeType: string }; + expect(meta.filename).toBe('comment.txt'); + + const storedPath = path.join( + claudeDir, + 'teams', + TEAM, + 'task-attachments', + '1', + `${meta.id}--${meta.filename}` + ); + expect(fs.existsSync(storedPath)).toBe(true); + + const taskAfterAttach = readTask(claudeDir, '1'); + const comment = (taskAfterAttach.comments as Record[]).find( + (c) => String(c.id) === commentId + ) as Record; + expect(comment).toBeDefined(); + const attachments = comment.attachments as Record[]; + expect(attachments).toHaveLength(1); + expect(attachments[0].id).toBe(meta.id); + expect(attachments[0].filename).toBe(meta.filename); + expect(attachments[0].mimeType).toBe(meta.mimeType); + }); + + it('task attach with --mode link succeeds (may fall back to copy)', () => { + expect(run(claudeDir, ['task', 'create', '--subject', 'Link mode']).exitCode).toBe(0); + + const samplePath = path.join(claudeDir, 'link.txt'); + fs.writeFileSync(samplePath, 'link'); + + const { stdout, exitCode } = run(claudeDir, [ + 'task', + 'attach', + '1', + '--file', + samplePath, + '--mode', + 'link', + ]); + expect(exitCode).toBe(0); + const meta = JSON.parse(stdout) as { id: string; filename: string }; + const storedPath = path.join( + claudeDir, + 'teams', + TEAM, + 'task-attachments', + '1', + `${meta.id}--${meta.filename}` + ); + expect(fs.existsSync(storedPath)).toBe(true); + }); + }); + // ========================================================================= // Comment Auto-Clear needsClarification // =========================================================================