diff --git a/src/main/http/config.ts b/src/main/http/config.ts index 6d35c66d..c5acd2fc 100644 --- a/src/main/http/config.ts +++ b/src/main/http/config.ts @@ -17,6 +17,8 @@ * - POST /api/config/triggers/:triggerId/test - Test trigger * - POST /api/config/pin-session - Pin session * - POST /api/config/unpin-session - Unpin session + * - POST /api/config/add-custom-project-path - Add custom project path + * - POST /api/config/remove-custom-project-path - Remove custom project path * - POST /api/config/select-folders - No-op in browser * - POST /api/config/open-in-editor - No-op in browser */ @@ -470,6 +472,44 @@ export function registerConfigRoutes(app: FastifyInstance): void { } ); + // Add custom project path + app.post<{ Body: { projectPath: string } }>( + '/api/config/add-custom-project-path', + async (request): Promise => { + try { + const { projectPath } = request.body; + if (!projectPath || typeof projectPath !== 'string') { + return { success: false, error: 'Project path is required and must be a string' }; + } + + configManager.addCustomProjectPath(projectPath); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/add-custom-project-path:', error); + return { success: false, error: getErrorMessage(error) }; + } + } + ); + + // Remove custom project path + app.post<{ Body: { projectPath: string } }>( + '/api/config/remove-custom-project-path', + async (request): Promise => { + try { + const { projectPath } = request.body; + if (!projectPath || typeof projectPath !== 'string') { + return { success: false, error: 'Project path is required and must be a string' }; + } + + configManager.removeCustomProjectPath(projectPath); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/remove-custom-project-path:', error); + return { success: false, error: getErrorMessage(error) }; + } + } + ); + // Select folders - no-op in browser mode app.post('/api/config/select-folders', async (): Promise> => { return { success: true, data: [] }; diff --git a/src/main/index.ts b/src/main/index.ts index 1bab4bc8..38699eee 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -126,6 +126,61 @@ 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":"..."}). + */ +function extractNotificationContent(text: string): { summary: string; body: string } { + const parsed = parseInboxJson(text); + if (!parsed) return { summary: text.slice(0, 80), body: text }; + + const content = typeof parsed.content === 'string' ? parsed.content : null; + const summary = typeof parsed.summary === 'string' ? parsed.summary : null; + const message = typeof parsed.message === 'string' ? parsed.message : null; + + const bestBody = content || message || summary || text; + const bestSummary = + summary || (content ? content.slice(0, 80) : null) || message || text.slice(0, 80); + + return { summary: bestSummary, body: bestBody }; +} + async function notifyNewInboxMessages(teamName: string, detail: string): Promise { // Check config toggle const config = configManager.getConfig(); @@ -145,10 +200,11 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise try { const messages = await teamInboxReader.getMessagesFor(teamName, memberName); + const isFirstLoad = !inboxMessageCounts.has(key); const prevCount = inboxMessageCounts.get(key) ?? 0; - if (prevCount === 0) { - // First load — seed count, don't notify + if (isFirstLoad) { + // First load — seed count, don't notify for pre-existing messages inboxMessageCounts.set(key, messages.length); return; } @@ -167,14 +223,17 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise for (const msg of newMessages) { // Skip messages sent from our own UI if (msg.source && suppressedSources.has(msg.source)) continue; + // Skip internal coordination noise (idle_notification, shutdown_*, etc.) + if (isInboxNoiseMessage(msg.text)) continue; const fromLabel = msg.from || 'Unknown'; - const summary = msg.summary || msg.text.slice(0, 60); + const extracted = extractNotificationContent(msg.text); + const summary = msg.summary || extracted.summary; showTeamNativeNotification({ title: teamDisplayName, subtitle: `${fromLabel}: ${summary}`, - body: msg.text, + body: extracted.body, }); } } catch (error) { @@ -344,16 +403,17 @@ function wireFileWatcherEvents(context: ServiceContext): void { if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) { const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName); const latest = messages.length > 0 ? messages[messages.length - 1] : undefined; - // Only notify for messages addressed to the human user - if (latest?.to === 'user') { + // Only notify for messages addressed to the human user, skip noise + if (latest?.to === 'user' && !isInboxNoiseMessage(latest.text)) { const fromLabel = latest.from || 'team-lead'; - const summary = latest.summary || latest.text.slice(0, 60); + const extracted = extractNotificationContent(latest.text); + const summary = latest.summary || extracted.summary; void resolveTeamDisplayName(teamName) .then((displayName) => { showTeamNativeNotification({ title: displayName, subtitle: `${fromLabel}: ${summary}`, - body: latest.text, + body: extracted.body, }); }) .catch(() => undefined); diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index 0655fe3c..85eb0c76 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -114,6 +114,10 @@ export function registerConfigHandlers(ipcMain: IpcMain): void { ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo); ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots); + // Custom project path handlers + ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath); + ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath); + // Editor handlers ipcMain.handle('config:openInEditor', handleOpenInEditor); @@ -624,6 +628,46 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise { + try { + if (!projectPath || typeof projectPath !== 'string') { + return { success: false, error: 'Project path is required and must be a string' }; + } + + configManager.addCustomProjectPath(projectPath); + return { success: true }; + } catch (error) { + logger.error('Error in config:addCustomProjectPath:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:removeCustomProjectPath' - Removes a custom project path. + */ +async function handleRemoveCustomProjectPath( + _event: IpcMainInvokeEvent, + projectPath: string +): Promise { + try { + if (!projectPath || typeof projectPath !== 'string') { + return { success: false, error: 'Project path is required and must be a string' }; + } + + configManager.removeCustomProjectPath(projectPath); + return { success: true }; + } catch (error) { + logger.error('Error in config:removeCustomProjectPath:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + /** * Handler for 'config:selectFolders' - Opens native folder selection dialog. * Allows users to select one or more folders for trigger project scope. @@ -1091,6 +1135,8 @@ export function removeConfigHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('config:unhideSession'); ipcMain.removeHandler('config:hideSessions'); ipcMain.removeHandler('config:unhideSessions'); + ipcMain.removeHandler('config:addCustomProjectPath'); + ipcMain.removeHandler('config:removeCustomProjectPath'); ipcMain.removeHandler('config:selectFolders'); ipcMain.removeHandler('config:selectClaudeRootFolder'); ipcMain.removeHandler('config:getClaudeRootInfo'); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index fc383f46..c0d45af6 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -4,6 +4,7 @@ import { getAppIconPath } from '@main/utils/appIcon'; import { TEAM_ADD_MEMBER, TEAM_ADD_TASK_COMMENT, + TEAM_ADD_TASK_RELATIONSHIP, TEAM_ALIVE_LIST, TEAM_CANCEL_PROVISIONING, TEAM_CREATE, @@ -29,6 +30,7 @@ import { TEAM_PROVISIONING_PROGRESS, TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, + TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, @@ -216,6 +218,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification); ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification); + ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship); + ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship); logger.info('Team handlers registered'); } @@ -262,6 +266,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION); ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION); + ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP); + ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP); } function getTeamDataService(): TeamDataService { @@ -1715,14 +1721,21 @@ export function showTeamNativeNotification(opts: { body: string; }): void { const config = ConfigManager.getInstance().getConfig(); - if (!config.notifications.enabled) return; - if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) return; + if (!config.notifications.enabled) { + logger.debug('[native-notification] skipped: notifications disabled'); + return; + } + if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) { + logger.debug('[native-notification] skipped: snoozed'); + return; + } if ( typeof Notification === 'undefined' || typeof Notification.isSupported !== 'function' || !Notification.isSupported() ) { + logger.warn('[native-notification] skipped: Notification not supported on this platform'); return; } @@ -1744,6 +1757,14 @@ export function showTeamNativeNotification(opts: { } }); + notification.on('show', () => { + logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`); + }); + + notification.on('failed', (_, error) => { + logger.warn(`[native-notification] failed: ${error}`); + }); + notification.show(); } @@ -1766,3 +1787,66 @@ async function handleAddTaskComment( getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim()) ); } + +const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const; +type RelationshipType = (typeof VALID_RELATIONSHIP_TYPES)[number]; + +async function handleAddTaskRelationship( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + targetId: unknown, + type: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + const vTask = validateTaskId(taskId); + if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + const vTarget = validateTaskId(targetId); + if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' }; + if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) { + return { + success: false, + error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`, + }; + } + + return wrapTeamHandler('addTaskRelationship', () => + getTeamDataService().addTaskRelationship( + vTeam.value!, + vTask.value!, + vTarget.value!, + type as RelationshipType + ) + ); +} + +async function handleRemoveTaskRelationship( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + targetId: unknown, + type: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + const vTask = validateTaskId(taskId); + if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + const vTarget = validateTaskId(targetId); + if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' }; + if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) { + return { + success: false, + error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`, + }; + } + + return wrapTeamHandler('removeTaskRelationship', () => + getTeamDataService().removeTaskRelationship( + vTeam.value!, + vTask.value!, + vTarget.value!, + type as RelationshipType + ) + ); +} diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 2acfc29f..98b3bef9 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -196,10 +196,6 @@ export class ProjectScanner { // 1. Scan all projects using existing logic const projects = await this.scan(); - if (projects.length === 0) { - return []; - } - // 2. Convert each project to a simple RepositoryGroup (git resolution disabled) // Git identity resolution is bypassed to avoid blocking I/O on startup. // Each project becomes a single-worktree group. @@ -223,6 +219,40 @@ export class ProjectScanner { totalSessions: project.sessions.length, })); + // 3. Merge custom project paths from config (persisted "Select Folder" picks) + const { configManager } = await import('../infrastructure/ConfigManager'); + const customPaths = configManager.getCustomProjectPaths(); + const existingPaths = new Set(groups.flatMap((g) => g.worktrees.map((w) => w.path))); + + for (const customPath of customPaths) { + if (existingPaths.has(customPath)) { + continue; // Already discovered by scanner — skip + } + + const encodedId = customPath.replace(/[/\\]/g, '-'); + const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath; + const now = Date.now(); + + groups.push({ + id: encodedId, + identity: null, + worktrees: [ + { + id: encodedId, + path: customPath, + name: folderName, + isMainWorktree: true, + source: 'unknown' as const, + sessions: [], + createdAt: now, + }, + ], + name: folderName, + mostRecentSession: undefined, + totalSessions: 0, + }); + } + // Sort by most recent activity (same order as the full git-aware version) groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 6fe384aa..ee54eb61 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -188,6 +188,8 @@ export interface GeneralConfig { agentLanguage: string; autoExpandAIGroups: boolean; useNativeTitleBar: boolean; + /** Paths manually added via "Select Folder" that persist across app restarts */ + customProjectPaths: string[]; } export interface DisplayConfig { @@ -260,6 +262,7 @@ const DEFAULT_CONFIG: AppConfig = { agentLanguage: 'system', autoExpandAIGroups: false, useNativeTitleBar: false, + customProjectPaths: [], }, display: { showTimestamps: true, @@ -845,6 +848,58 @@ export class ConfigManager { this.saveConfig(); } + // =========================================================================== + // Custom Project Path Management + // =========================================================================== + + /** + * Adds a custom project path (from "Select Folder" dialog). + * Persisted across app restarts. + * @param projectPath - Absolute filesystem path to the project + */ + addCustomProjectPath(projectPath: string): void { + if (!projectPath || projectPath.trim().length === 0) { + return; + } + + const normalized = path.normalize(projectPath.trim()); + if (!path.isAbsolute(normalized)) { + return; + } + + if (this.config.general.customProjectPaths.includes(normalized)) { + return; + } + + this.config.general.customProjectPaths.push(normalized); + this.saveConfig(); + logger.info(`Custom project path added: ${normalized}`); + } + + /** + * Removes a custom project path. + * @param projectPath - The path to remove + */ + removeCustomProjectPath(projectPath: string): void { + const normalized = path.normalize(projectPath.trim()); + const index = this.config.general.customProjectPaths.indexOf(normalized); + if (index === -1) { + return; + } + + this.config.general.customProjectPaths.splice(index, 1); + this.saveConfig(); + logger.info(`Custom project path removed: ${normalized}`); + } + + /** + * Gets all custom project paths. + * @returns Array of absolute filesystem paths + */ + getCustomProjectPaths(): string[] { + return [...this.config.general.customProjectPaths]; + } + // =========================================================================== // SSH Profile Management // =========================================================================== diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index ad268f4e..d2cb1445 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -305,6 +305,103 @@ function updateHighwatermark(paths, taskId) { atomicWrite(hwmPath, String(taskId)); } +function parseIdList(value) { + if (!value || value === true) return []; + var ids = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean); + for (var k = 0; k < ids.length; k++) { + if (!/^\d+$/.test(ids[k])) die('Invalid task ID in list: ' + ids[k]); + } + return ids; +} + +function taskExists(paths, taskId) { + try { + fs.accessSync(path.join(paths.tasksDir, String(taskId) + '.json'), fs.constants.F_OK); + return true; + } catch (e) { return false; } +} + +function readTaskObject(paths, taskId) { + var taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); + var t = readJson(taskPath, null); + if (!t) die('Task not found: #' + taskId); + return { task: t, taskPath: taskPath }; +} + +function wouldCreateBlockCycle(paths, sourceId, targetId) { + var visited = {}; + var stack = [String(targetId)]; + while (stack.length > 0) { + var current = stack.pop(); + if (current === String(sourceId)) return true; + if (visited[current]) continue; + visited[current] = true; + try { + var t = readJson(path.join(paths.tasksDir, current + '.json'), null); + if (t && Array.isArray(t.blockedBy)) { + for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i])); + } + } catch (e) { /* skip */ } + } + return false; +} + +function linkTasks(paths, taskId, targetId, type) { + var id = String(taskId), target = String(targetId); + if (id === target) die('Cannot link a task to itself'); + if (!taskExists(paths, id)) die('Task not found: #' + id); + if (!taskExists(paths, target)) die('Task not found: #' + target); + + if (type === 'blocked-by') { + if (wouldCreateBlockCycle(paths, id, target)) + die('Circular dependency: #' + target + ' already depends on #' + id); + var refA = readTaskObject(paths, id); + var bb = Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : []; + if (!bb.includes(target)) { refA.task.blockedBy = bb.concat([target]); atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2)); } + var refB = readTaskObject(paths, target); + var bl = Array.isArray(refB.task.blocks) ? refB.task.blocks : []; + if (!bl.includes(id)) { refB.task.blocks = bl.concat([id]); atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2)); } + } else if (type === 'blocks') { + linkTasks(paths, target, id, 'blocked-by'); + return; + } else if (type === 'related') { + var rA = readTaskObject(paths, id); + var relA = Array.isArray(rA.task.related) ? rA.task.related : []; + if (!relA.includes(target)) { rA.task.related = relA.concat([target]); atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2)); } + var rB = readTaskObject(paths, target); + var relB = Array.isArray(rB.task.related) ? rB.task.related : []; + if (!relB.includes(id)) { rB.task.related = relB.concat([id]); atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2)); } + } +} + +function unlinkTasks(paths, taskId, targetId, type) { + var id = String(taskId), target = String(targetId); + if (!taskExists(paths, id)) die('Task not found: #' + id); + + if (type === 'blocked-by') { + var refA = readTaskObject(paths, id); + refA.task.blockedBy = (Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : []).filter(function(x) { return x !== target; }); + atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2)); + if (taskExists(paths, target)) { + var refB = readTaskObject(paths, target); + refB.task.blocks = (Array.isArray(refB.task.blocks) ? refB.task.blocks : []).filter(function(x) { return x !== id; }); + atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2)); + } + } else if (type === 'blocks') { + unlinkTasks(paths, target, id, 'blocked-by'); + return; + } else if (type === 'related') { + var rA = readTaskObject(paths, id); + rA.task.related = (Array.isArray(rA.task.related) ? rA.task.related : []).filter(function(x) { return x !== target; }); + atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2)); + if (taskExists(paths, target)) { + var rB = readTaskObject(paths, target); + rB.task.related = (Array.isArray(rB.task.related) ? rB.task.related : []).filter(function(x) { return x !== id; }); + atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2)); + } + } +} + function createTask(paths, flags) { const subject = typeof flags.subject === 'string' ? flags.subject.trim() : ''; if (!subject) die('Missing --subject'); @@ -324,6 +421,11 @@ function createTask(paths, flags) { ? flags['active-form'] : undefined; + var blockedByIds = parseIdList(flags['blocked-by']); + var relatedIds = parseIdList(flags.related); + for (var v = 0; v < blockedByIds.length; v++) { if (!taskExists(paths, blockedByIds[v])) die('Blocked-by task not found: #' + blockedByIds[v]); } + for (var w = 0; w < relatedIds.length; w++) { if (!taskExists(paths, relatedIds[w])) die('Related task not found: #' + relatedIds[w]); } + ensureDir(paths.tasksDir); const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined; let nextId; @@ -341,7 +443,8 @@ function createTask(paths, flags) { createdBy: from, status, blocks: [], - blockedBy: [], + blockedBy: blockedByIds, + related: relatedIds.length > 0 ? relatedIds : undefined, }; try { const fd = fs.openSync(taskPath, 'wx'); @@ -356,6 +459,20 @@ function createTask(paths, flags) { } } updateHighwatermark(paths, nextId); + + // Set reverse links for blockedBy (target.blocks += nextId) + for (var bi = 0; bi < blockedByIds.length; bi++) { + var dep = readTaskObject(paths, blockedByIds[bi]); + var depBl = Array.isArray(dep.task.blocks) ? dep.task.blocks : []; + if (!depBl.includes(nextId)) { dep.task.blocks = depBl.concat([nextId]); atomicWrite(dep.taskPath, JSON.stringify(dep.task, null, 2)); } + } + // Set reverse links for related (bidirectional) + for (var ri = 0; ri < relatedIds.length; ri++) { + var rel = readTaskObject(paths, relatedIds[ri]); + var relL = Array.isArray(rel.task.related) ? rel.task.related : []; + if (!relL.includes(nextId)) { rel.task.related = relL.concat([nextId]); atomicWrite(rel.taskPath, JSON.stringify(rel.task, null, 2)); } + } + return task; } @@ -638,6 +755,9 @@ function taskBriefing(paths, teamName, flags) { if (t.description && t.description !== t.subject) { parts.push(' Description: ' + t.description.slice(0, 500)); } + if (t.blocks && t.blocks.length > 0) { + parts.push(' Blocks: ' + t.blocks.map(function(id) { return '#' + id; }).join(', ')); + } if (t.blockedBy && t.blockedBy.length > 0) { parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', ')); } @@ -703,7 +823,13 @@ function printHelp() { ' node teamctl.js task set-status [--team ]', ' node teamctl.js task complete [--team ]', ' node teamctl.js task start [--team ]', - ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team ]', + ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--blocked-by 2,3] [--related 5] [--status ...] [--notify --from "member"] [--team ]', + ' node teamctl.js task link --blocked-by [--team ]', + ' node teamctl.js task link --blocks [--team ]', + ' node teamctl.js task link --related [--team ]', + ' node teamctl.js task unlink --blocked-by [--team ]', + ' node teamctl.js task unlink --blocks [--team ]', + ' 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 set-clarification [--from "member"] [--team ]', @@ -876,6 +1002,30 @@ async function main() { taskBriefing(paths, teamName, args.flags); return; } + if (action === 'link') { + var linkId = rest[0] || args.flags.id; + if (!linkId) die('Usage: task link --blocked-by|--blocks|--related '); + var linkBbF = args.flags['blocked-by'], linkBlF = args.flags.blocks, linkRelF = args.flags.related; + var linkCnt = (linkBbF ? 1 : 0) + (linkBlF ? 1 : 0) + (linkRelF ? 1 : 0); + if (linkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related'); + var linkTp = linkBbF ? 'blocked-by' : linkBlF ? 'blocks' : 'related'; + var linkTv = linkBbF || linkBlF || linkRelF; + linkTasks(paths, String(linkId), String(linkTv), linkTp); + process.stdout.write('OK task #' + linkId + ' ' + linkTp + ' #' + linkTv + '\n'); + return; + } + if (action === 'unlink') { + var unlinkId = rest[0] || args.flags.id; + if (!unlinkId) die('Usage: task unlink --blocked-by|--blocks|--related '); + var unlinkBbF = args.flags['blocked-by'], unlinkBlF = args.flags.blocks, unlinkRelF = args.flags.related; + var unlinkCnt = (unlinkBbF ? 1 : 0) + (unlinkBlF ? 1 : 0) + (unlinkRelF ? 1 : 0); + if (unlinkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related'); + var unlinkTp = unlinkBbF ? 'blocked-by' : unlinkBlF ? 'blocks' : 'related'; + var unlinkTv = unlinkBbF || unlinkBlF || unlinkRelF; + unlinkTasks(paths, String(unlinkId), String(unlinkTv), unlinkTp); + process.stdout.write('OK task #' + unlinkId + ' unlinked ' + unlinkTp + ' #' + unlinkTv + '\n'); + return; + } die('Unknown task action: ' + String(action)); } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 66f70a9a..7af56c9d 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -783,6 +783,24 @@ export class TeamDataService { await this.taskWriter.setNeedsClarification(teamName, taskId, value); } + async addTaskRelationship( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ): Promise { + await this.taskWriter.addRelationship(teamName, taskId, targetId, type); + } + + async removeTaskRelationship( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ): Promise { + await this.taskWriter.removeRelationship(teamName, taskId, targetId, type); + } + async addTaskComment(teamName: string, taskId: string, text: string): Promise { const comment = await this.taskWriter.addComment(teamName, taskId, text); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 57083969..4675fead 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -350,6 +350,9 @@ function buildTaskStatusProtocol(teamName: string): string { If the lead replies via SendMessage instead, clear the flag yourself once you have the answer: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification clear --from "" d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user. +11. DEPENDENCY AWARENESS: + When your task has blockedBy dependencies, check if they are completed before starting. + When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed. Failure to follow this protocol means the task board will show incorrect status.`); } @@ -377,6 +380,15 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner --notify --from "${leadName}"`, `- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner clear`, `- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status `, + `- Create with deps: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --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 `, + ``, + `Dependency guidelines:`, + `- Use --blocked-by when a task cannot start until another is done.`, + `- Use --related to link related work (e.g. frontend + backend) without blocking.`, + `- Avoid over-specifying. Only add dependencies when execution order matters.`, ``, `Notification policy:`, `- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`, @@ -445,7 +457,10 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string const lines = activeTasks.map((t) => { const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; - return ` - #${t.id} [${t.status}] ${t.subject}${desc}`; + const deps = t.blockedBy?.length + ? ` [blocked by: ${t.blockedBy.map((id) => `#${id}`).join(', ')}]` + : ''; + return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`; }); return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`; } @@ -460,7 +475,10 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { const lines = active.map((t) => { const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)'; const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; - return ` - #${t.id} [${t.status}]${owner} ${t.subject}${desc}`; + const deps = t.blockedBy?.length + ? ` [blocked by: ${t.blockedBy.map((id) => `#${id}`).join(', ')}]` + : ''; + return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`; }); return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`; } @@ -535,6 +553,8 @@ ${processRegistration} 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. + - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. + - Use --related to connect tasks working on the same feature without blocking. 4) After all steps, output a short summary. diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 94e9b9ba..b337e27c 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -92,6 +92,172 @@ export class TeamTaskWriter { }); } + async addRelationship( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ): Promise { + if (taskId === targetId) { + throw new Error('Cannot link a task to itself'); + } + + // For 'blocks', delegate as reverse blockedBy + if (type === 'blocks') { + return this.addRelationship(teamName, targetId, taskId, 'blockedBy'); + } + + const tasksDir = path.join(getTasksBasePath(), teamName); + const taskPath = path.join(tasksDir, `${taskId}.json`); + const targetPath = path.join(tasksDir, `${targetId}.json`); + + // Lock both paths in sorted order to avoid deadlocks + const [firstPath, secondPath] = + taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath]; + + await withTaskLock(firstPath, () => + withTaskLock(secondPath, async () => { + // Read both tasks + const taskRaw = await this.readTaskFile(taskPath, taskId); + const targetRaw = await this.readTaskFile(targetPath, targetId); + const task = JSON.parse(taskRaw) as TeamTask; + const target = JSON.parse(targetRaw) as TeamTask; + + if (type === 'blockedBy') { + // Cycle detection: walk target's blockedBy chain to check if taskId is reachable + await this.checkBlockCycle(tasksDir, taskId, targetId); + + // task.blockedBy += targetId + const blockedBy = task.blockedBy ?? []; + if (!blockedBy.includes(targetId)) { + task.blockedBy = [...blockedBy, targetId]; + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + } + // target.blocks += taskId (reverse) + const blocks = target.blocks ?? []; + if (!blocks.includes(taskId)) { + target.blocks = [...blocks, taskId]; + await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2)); + } + } else { + // related — bidirectional + const relA = task.related ?? []; + if (!relA.includes(targetId)) { + task.related = [...relA, targetId]; + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + } + const relB = target.related ?? []; + if (!relB.includes(taskId)) { + target.related = [...relB, taskId]; + await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2)); + } + } + }) + ); + } + + async removeRelationship( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ): Promise { + // For 'blocks', delegate as reverse blockedBy + if (type === 'blocks') { + return this.removeRelationship(teamName, targetId, taskId, 'blockedBy'); + } + + const tasksDir = path.join(getTasksBasePath(), teamName); + const taskPath = path.join(tasksDir, `${taskId}.json`); + const targetPath = path.join(tasksDir, `${targetId}.json`); + + const [firstPath, secondPath] = + taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath]; + + await withTaskLock(firstPath, () => + withTaskLock(secondPath, async () => { + // Read task (must exist) + const taskRaw = await this.readTaskFile(taskPath, taskId); + const task = JSON.parse(taskRaw) as TeamTask; + + if (type === 'blockedBy') { + task.blockedBy = (task.blockedBy ?? []).filter((id) => id !== targetId); + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + + // Remove reverse from target if it exists + try { + const targetRaw = await fs.promises.readFile(targetPath, 'utf8'); + const target = JSON.parse(targetRaw) as TeamTask; + target.blocks = (target.blocks ?? []).filter((id) => id !== taskId); + await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + // Target doesn't exist — skip silently + } + } else { + // related — remove bidirectional + task.related = (task.related ?? []).filter((id) => id !== targetId); + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + + try { + const targetRaw = await fs.promises.readFile(targetPath, 'utf8'); + const target = JSON.parse(targetRaw) as TeamTask; + target.related = (target.related ?? []).filter((id) => id !== taskId); + await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + }) + ); + } + + private async readTaskFile(taskPath: string, taskId: string): Promise { + try { + return await fs.promises.readFile(taskPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Task not found: ${taskId}`); + } + throw error; + } + } + + /** + * Walks targetId's blockedBy chain to detect if sourceId is reachable. + * Reads are outside locks (deliberate TOCTOU trade-off — the calling method + * holds locks on both source and target, and only other tasks are read here). + */ + private async checkBlockCycle( + tasksDir: string, + sourceId: string, + targetId: string + ): Promise { + const visited = new Set(); + const stack = [targetId]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (current === sourceId) { + throw new Error(`Circular dependency: #${targetId} already depends on #${sourceId}`); + } + if (visited.has(current)) continue; + visited.add(current); + + try { + const raw = await fs.promises.readFile(path.join(tasksDir, `${current}.json`), 'utf8'); + const task = JSON.parse(raw) as TeamTask; + if (Array.isArray(task.blockedBy)) { + for (const dep of task.blockedBy) { + stack.push(dep); + } + } + } catch { + // Skip unreadable tasks + } + } + } + async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 02399f53..43ac301e 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -62,6 +62,12 @@ export const CONFIG_FIND_WSL_CLAUDE_ROOTS = 'config:findWslClaudeRoots'; /** Open config file in external editor */ export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor'; +/** Add a custom project path (Select Folder persistence) */ +export const CONFIG_ADD_CUSTOM_PROJECT_PATH = 'config:addCustomProjectPath'; + +/** Remove a custom project path */ +export const CONFIG_REMOVE_CUSTOM_PROJECT_PATH = 'config:removeCustomProjectPath'; + /** Pin a session */ export const CONFIG_PIN_SESSION = 'config:pinSession'; @@ -315,6 +321,12 @@ export const TEAM_SET_TASK_CLARIFICATION = 'team:setTaskClarification'; /** Show native OS notification for a team message */ export const TEAM_SHOW_MESSAGE_NOTIFICATION = 'team:showMessageNotification'; +/** Add a relationship (blockedBy/blocks/related) between two tasks */ +export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship'; + +/** Remove a relationship (blockedBy/blocks/related) between two tasks */ +export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship'; + // ============================================================================= // CLI Installer API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index c1d911a1..fa564b06 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -53,6 +53,7 @@ import { SSH_TEST, TEAM_ADD_MEMBER, TEAM_ADD_TASK_COMMENT, + TEAM_ADD_TASK_RELATIONSHIP, TEAM_ALIVE_LIST, TEAM_CANCEL_PROVISIONING, TEAM_CHANGE, @@ -79,6 +80,7 @@ import { TEAM_PROVISIONING_PROGRESS, TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, + TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, @@ -113,6 +115,7 @@ import { WINDOW_MINIMIZE, } from './constants/ipcChannels'; import { + CONFIG_ADD_CUSTOM_PROJECT_PATH, CONFIG_ADD_IGNORE_REGEX, CONFIG_ADD_IGNORE_REPOSITORY, CONFIG_ADD_TRIGGER, @@ -125,6 +128,7 @@ import { CONFIG_HIDE_SESSIONS, CONFIG_OPEN_IN_EDITOR, CONFIG_PIN_SESSION, + CONFIG_REMOVE_CUSTOM_PROJECT_PATH, CONFIG_REMOVE_IGNORE_REGEX, CONFIG_REMOVE_IGNORE_REPOSITORY, CONFIG_REMOVE_TRIGGER, @@ -442,6 +446,12 @@ const electronAPI: ElectronAPI = { unhideSessions: async (projectId: string, sessionIds: string[]): Promise => { return invokeIpcWithResult(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds); }, + addCustomProjectPath: async (projectPath: string): Promise => { + return invokeIpcWithResult(CONFIG_ADD_CUSTOM_PROJECT_PATH, projectPath); + }, + removeCustomProjectPath: async (projectPath: string): Promise => { + return invokeIpcWithResult(CONFIG_REMOVE_CUSTOM_PROJECT_PATH, projectPath); + }, }, // Deep link navigation @@ -759,6 +769,34 @@ const electronAPI: ElectronAPI = { showMessageNotification: async (data: TeamMessageNotificationData) => { return invokeIpcWithResult(TEAM_SHOW_MESSAGE_NOTIFICATION, data); }, + addTaskRelationship: async ( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ) => { + return invokeIpcWithResult( + TEAM_ADD_TASK_RELATIONSHIP, + teamName, + taskId, + targetId, + type + ); + }, + removeTaskRelationship: async ( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ) => { + return invokeIpcWithResult( + TEAM_REMOVE_TASK_RELATIONSHIP, + teamName, + taskId, + targetId, + type + ); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { ipcRenderer.on( TEAM_CHANGE, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 0e3d6b01..ff531760 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -481,6 +481,10 @@ export class HttpAPIClient implements ElectronAPI { this.post('/api/config/hide-sessions', { projectId, sessionIds }), unhideSessions: (projectId: string, sessionIds: string[]): Promise => this.post('/api/config/unhide-sessions', { projectId, sessionIds }), + addCustomProjectPath: (projectPath: string): Promise => + this.post('/api/config/add-custom-project-path', { projectPath }), + removeCustomProjectPath: (projectPath: string): Promise => + this.post('/api/config/remove-custom-project-path', { projectPath }), }; // --------------------------------------------------------------------------- @@ -808,6 +812,22 @@ export class HttpAPIClient implements ElectronAPI { showMessageNotification: async (): Promise => { // Not available via HTTP client — native notifications require Electron }, + addTaskRelationship: async ( + _teamName: string, + _taskId: string, + _targetId: string, + _type: 'blockedBy' | 'blocks' | 'related' + ): Promise => { + throw new Error('Task relationships are not available in browser mode'); + }, + removeTaskRelationship: async ( + _teamName: string, + _taskId: string, + _targetId: string, + _type: 'blockedBy' | 'blocks' | 'related' + ): Promise => { + throw new Error('Task relationships are not available in browser mode'); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { return this.addEventListener('team-change', (data: unknown) => callback(null, data as TeamChangeEvent) diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 0469cc5b..d06451df 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -413,6 +413,9 @@ const NewProjectCard = (): React.JSX.Element => { // Still no match — create a synthetic group for this new folder and navigate to it. // This allows launching teams in projects that don't have Claude sessions yet. + // Persist the path so it survives app restarts. + await api.config.addCustomProjectPath(selectedPath); + const encodedId = selectedPath.replace(/[/\\]/g, '-'); const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; const now = Date.now(); diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index e3dfaaa2..bca37d17 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -6,6 +6,8 @@ interface MemberBadgeProps { color?: string; /** Avatar + badge size variant */ size?: 'sm' | 'md'; + /** Hide the avatar icon, show only the name badge */ + hideAvatar?: boolean; onClick?: (name: string) => void; } @@ -18,6 +20,7 @@ export const MemberBadge = ({ name, color, size = 'sm', + hideAvatar, onClick, }: MemberBadgeProps): React.JSX.Element => { const colors = getTeamColorSet(color ?? ''); @@ -59,7 +62,7 @@ export const MemberBadge = ({ onClick(name); }} > - {avatar} + {!hideAvatar && avatar} {badge} ); @@ -67,7 +70,7 @@ export const MemberBadge = ({ return ( - {avatar} + {!hideAvatar && avatar} {badge} ); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 09e21be9..219a1a3b 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -317,7 +317,12 @@ export const ActivityItem = ({ - + ) : null} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index dd79755e..88df02d8 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -34,6 +34,7 @@ const MessageRowWithObserver = ({ memberColor, recipientColor, isUnread, + isNew, onMemberNameClick, onCreateTask, onReply, @@ -46,6 +47,7 @@ const MessageRowWithObserver = ({ memberColor?: string; recipientColor?: string; isUnread?: boolean; + isNew?: boolean; onMemberNameClick?: (name: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; @@ -83,7 +85,7 @@ const MessageRowWithObserver = ({ }, [onVisible]); return ( -
+
{ const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); + // --- New-message animation tracking --- + const knownKeysRef = useRef>(new Set()); + const isInitializedRef = useRef(false); + const prevVisibleCountRef = useRef(visibleCount); + // Track whether the user was seeing ALL messages (no hidden ones). // If so, auto-expand when new messages push count past the limit, // so previously visible messages don't silently disappear. @@ -152,19 +159,57 @@ export const ActivityTimeline = ({ // Auto-expand when user was seeing all and new messages arrive — derived state sync. // Reading/updating ref during render is intentional (React docs: derived state sync). - /* eslint-disable react-hooks/refs -- ref stores previous frame's "showing all" for derived state sync */ + const wasShowingAll = wasShowingAllRef.current; if (wasShowingAll && hiddenCount > 0) { setVisibleCount(messages.length); } wasShowingAllRef.current = hiddenCount === 0; - /* eslint-enable react-hooks/refs -- end of intentional ref access during render */ const visibleMessages = useMemo( () => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages), [messages, visibleCount, hiddenCount] ); + // Determine which messages are "new" (should animate). + + const newMessageKeys = useMemo(() => { + const getKey = (msg: InboxMessage, idx: number): string => + `${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`; + + // 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)); + } + prevVisibleCountRef.current = visibleCount; + return new Set(); + } + + // Pagination expansion ("Show more" / "Show all"): add keys silently + const isPaginationExpansion = visibleCount > prevVisibleCountRef.current; + prevVisibleCountRef.current = visibleCount; + + if (isPaginationExpansion) { + for (let i = 0; i < visibleMessages.length; i++) { + knownKeysRef.current.add(getKey(visibleMessages[i], i)); + } + return new Set(); + } + + // Normal update: unknown keys are new messages + const newKeys = new Set(); + for (let i = 0; i < visibleMessages.length; i++) { + const key = getKey(visibleMessages[i], i); + if (!knownKeysRef.current.has(key)) { + newKeys.add(key); + knownKeysRef.current.add(key); + } + } + return newKeys; + }, [visibleMessages, visibleCount]); + const handleShowMore = (): void => { setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE); }; @@ -203,6 +248,7 @@ export const ActivityTimeline = ({ memberColor={info?.color} recipientColor={recipientColor} isUnread={isUnread} + isNew={newMessageKeys.has(messageKey)} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} onCreateTask={onCreateTaskFromMessage} onReply={onReplyToMessage} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index ec12f5e8..cfa9e521 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -247,6 +247,15 @@ export const ChangeReviewDialog = ({ [saveEditedFile, projectPath] ); + const handleRestoreMissingFile = useCallback( + (filePath: string, content: string) => { + updateEditedContent(filePath, content); + // Ensure editedContents is set before saveEditedFile reads it. + void Promise.resolve().then(() => saveEditedFile(filePath, projectPath)); + }, + [updateEditedContent, saveEditedFile, projectPath] + ); + const handleDiscardFile = useCallback( (filePath: string) => { discardFileEdits(filePath); @@ -783,6 +792,7 @@ export const ChangeReviewDialog = ({ onContentChanged={handleContentChanged} onDiscard={handleDiscardFile} onSave={handleSaveFile} + onRestoreMissingFile={handleRestoreMissingFile} onVisibleFileChange={handleVisibleFileChange} scrollContainerRef={scrollContainerRef} editorViewMapRef={editorViewMapRef} diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index 091a4722..adefaa7f 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -257,6 +257,13 @@ export const CodeMirrorDiffView = ({ syntaxHighlightDeletions: true, }; + // IMPORTANT: @codemirror/merge shows accept/reject buttons by default. + // When our UI chooses to hide merge controls (e.g. "Missing on disk" preview), + // explicitly disable them rather than relying on default behavior. + if (!showMergeControls) { + mergeConfig.mergeControls = false; + } + if (collapse && !usePortionCollapse) { mergeConfig.collapseUnchanged = { margin, diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index 570030d4..4f4f43e8 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -38,6 +38,7 @@ interface ContinuousScrollViewProps { onContentChanged: (filePath: string, content: string) => void; onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; + onRestoreMissingFile?: (filePath: string, content: string) => void; onVisibleFileChange: (filePath: string) => void; scrollContainerRef: React.RefObject; editorViewMapRef: React.MutableRefObject>; @@ -71,6 +72,7 @@ export const ContinuousScrollView = ({ onContentChanged, onDiscard, onSave, + onRestoreMissingFile, onVisibleFileChange, scrollContainerRef, editorViewMapRef, @@ -222,6 +224,7 @@ export const ContinuousScrollView = ({ onToggleCollapse={handleToggleCollapse} onDiscard={onDiscard} onSave={onSave} + onRestoreMissingFile={onRestoreMissingFile} /> {!isCollapsed && diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx index 0c236170..e4a109cb 100644 --- a/src/renderer/components/team/review/FileSectionDiff.tsx +++ b/src/renderer/components/team/review/FileSectionDiff.tsx @@ -93,6 +93,7 @@ export const FileSectionDiff = ({ })(); const resolvedOriginal = fileContent?.originalFullContent ?? null; + const isMissingOnDisk = fileContent?.contentSource === 'unavailable'; // Show CodeMirror only when we have a trustworthy original baseline: // - new files: original is legitimately empty @@ -114,6 +115,12 @@ export const FileSectionDiff = ({ return (
+ {isMissingOnDisk && ( +
+ File is missing on disk. This diff may be only a preview from agent logs. Use{' '} + Restore to create the file on disk. +
+ )} onHunkAccepted(file.filePath, idx)} diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx index 04f51a76..4de28ddc 100644 --- a/src/renderer/components/team/review/FileSectionHeader.tsx +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { ChevronDown, ChevronRight, Loader2, Save, Undo2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react'; import type { FileChangeWithContent, HunkDecision } from '@shared/types'; import type { FileChangeSummary } from '@shared/types/review'; @@ -11,7 +11,7 @@ const CONTENT_SOURCE_LABELS: Record = { 'snippet-reconstruction': 'Reconstructed', 'disk-current': 'Current Disk', 'git-fallback': 'Git Fallback', - unavailable: 'Unavailable', + unavailable: 'Missing on disk', }; interface FileSectionHeaderProps { @@ -24,6 +24,7 @@ interface FileSectionHeaderProps { onToggleCollapse: (filePath: string) => void; onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; + onRestoreMissingFile?: (filePath: string, content: string) => void; } export const FileSectionHeader = ({ @@ -36,7 +37,21 @@ export const FileSectionHeader = ({ onToggleCollapse, onDiscard, onSave, + onRestoreMissingFile, }: FileSectionHeaderProps): React.ReactElement => { + const isMissingOnDisk = fileContent?.contentSource === 'unavailable'; + const restoreContent = + fileContent?.modifiedFullContent ?? + (() => { + const writeSnippets = file.snippets.filter( + (s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update') + ); + if (writeSnippets.length === 0) return null; + return writeSnippets[writeSnippets.length - 1].newString; + })(); + const canRestore = + !!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent !== null; + const handleHeaderClick = (e: React.MouseEvent): void => { // Don't collapse when clicking action buttons if ((e.target as HTMLElement).closest('[data-no-collapse]')) return; @@ -70,9 +85,44 @@ export const FileSectionHeader = ({ )} {fileContent?.contentSource && ( - - {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource} - + + + + {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource} + + + + {fileContent.contentSource === 'unavailable' ? ( +
+
File is missing on disk
+
+ We can still show a preview from agent logs, but your filesystem is out of sync. +
+ {restoreContent !== null ? ( +
+ Use Restore to write the preview + content back to disk. +
+ ) : ( +
+ Full file content is not available to restore automatically. +
+ )} +
+ ) : ( + + {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource} + + )} +
+
)} {fileDecision && ( @@ -90,6 +140,23 @@ export const FileSectionHeader = ({ )}
+ {canRestore && restoreContent !== null && ( + + + + + + Create/restore this file on disk from the preview + + + )} {hasEdits && ( <> diff --git a/src/renderer/index.css b/src/renderer/index.css index aeea07e4..866a2da2 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -563,6 +563,21 @@ body { } } +@keyframes message-enter { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-enter-animate { + animation: message-enter 300ms ease-out both; +} + .skeleton-card { animation: skeleton-fade-in 0.4s ease-out both; position: relative; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index e1e7adb4..8805a40f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -175,6 +175,18 @@ export interface TeamSlice { memberName: string, role: string | undefined ) => Promise; + addTaskRelationship: ( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ) => Promise; + removeTaskRelationship: ( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ) => Promise; setTaskNeedsClarification: ( teamName: string, taskId: string, @@ -612,6 +624,20 @@ export const createTeamSlice: StateCreator = (set, await get().refreshTeamData(teamName); }, + addTaskRelationship: async (teamName, taskId, targetId, type) => { + await unwrapIpc('team:addTaskRelationship', () => + api.teams.addTaskRelationship(teamName, taskId, targetId, type) + ); + await get().refreshTeamData(teamName); + }, + + removeTaskRelationship: async (teamName, taskId, targetId, type) => { + await unwrapIpc('team:removeTaskRelationship', () => + api.teams.removeTaskRelationship(teamName, taskId, targetId, type) + ); + await get().refreshTeamData(teamName); + }, + setTaskNeedsClarification: async (teamName, taskId, value) => { await unwrapIpc('team:setTaskClarification', () => api.teams.setTaskClarification(teamName, taskId, value) diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9c942f6d..e0775528 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -184,6 +184,10 @@ export interface ConfigAPI { hideSessions: (projectId: string, sessionIds: string[]) => Promise; /** Bulk unhide sessions for a project */ unhideSessions: (projectId: string, sessionIds: string[]) => Promise; + /** Add a custom project path (persisted across restarts) */ + addCustomProjectPath: (projectPath: string) => Promise; + /** Remove a custom project path */ + removeCustomProjectPath: (projectPath: string) => Promise; } export interface ClaudeRootInfo { @@ -449,6 +453,18 @@ export interface TeamsAPI { restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; showMessageNotification: (data: TeamMessageNotificationData) => Promise; + addTaskRelationship: ( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ) => Promise; + removeTaskRelationship: ( + teamName: string, + taskId: string, + targetId: string, + type: 'blockedBy' | 'blocks' | 'related' + ) => Promise; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 3face9a8..3906797f 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -49,6 +49,8 @@ vi.mock('@preload/constants/ipcChannels', () => ({ 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', @@ -93,6 +95,8 @@ import { TEAM_SET_TASK_CLARIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_UPDATE_MEMBER_ROLE, + TEAM_ADD_TASK_RELATIONSHIP, + TEAM_REMOVE_TASK_RELATIONSHIP, } from '../../../src/preload/constants/ipcChannels'; import { initializeTeamHandlers, @@ -142,6 +146,8 @@ describe('ipc teams handlers', () => { softDeleteTask: vi.fn(async () => undefined), getDeletedTasks: vi.fn(async () => []), setTaskNeedsClarification: vi.fn(async () => undefined), + addTaskRelationship: vi.fn(async () => undefined), + removeTaskRelationship: vi.fn(async () => undefined), }; const provisioningService = { prepareForProvisioning: vi.fn(async () => ({ @@ -216,6 +222,8 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(true); expect(handlers.has(TEAM_RESTORE)).toBe(true); 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); }); it('returns success false on invalid sendMessage args', async () => { @@ -535,5 +543,77 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(false); expect(handlers.has(TEAM_RESTORE)).toBe(false); 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); + }); + + describe('addTaskRelationship', () => { + it('calls service on valid input', async () => { + const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, 'my-team', '1', '2', 'blockedBy')) as { + success: boolean; + }; + expect(result.success).toBe(true); + expect(service.addTaskRelationship).toHaveBeenCalledWith('my-team', '1', '2', 'blockedBy'); + }); + + it('rejects invalid team name', async () => { + const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, '../bad', '1', '2', 'blockedBy')) as { + success: boolean; + }; + expect(result.success).toBe(false); + }); + + it('rejects invalid task id', async () => { + const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, 'my-team', 'abc', '2', 'blockedBy')) as { + success: boolean; + }; + expect(result.success).toBe(false); + }); + + it('rejects invalid target id', async () => { + const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, 'my-team', '1', '', 'blockedBy')) as { + success: boolean; + }; + expect(result.success).toBe(false); + }); + + it('rejects invalid relationship type', async () => { + const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, 'my-team', '1', '2', 'invalid')) as { + success: boolean; + }; + expect(result.success).toBe(false); + }); + }); + + describe('removeTaskRelationship', () => { + it('calls service on valid input', async () => { + const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, 'my-team', '1', '2', 'related')) as { + success: boolean; + }; + expect(result.success).toBe(true); + expect(service.removeTaskRelationship).toHaveBeenCalledWith('my-team', '1', '2', 'related'); + }); + + it('rejects invalid team name', async () => { + const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, '../bad', '1', '2', 'related')) as { + success: boolean; + }; + expect(result.success).toBe(false); + }); + + it('rejects invalid relationship type', async () => { + const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!; + const result = (await handler({} as never, 'my-team', '1', '2', 'unknown')) as { + success: boolean; + }; + expect(result.success).toBe(false); + }); }); }); diff --git a/test/main/services/team/teamctlRelationships.test.ts b/test/main/services/team/teamctlRelationships.test.ts new file mode 100644 index 00000000..4538b49c --- /dev/null +++ b/test/main/services/team/teamctlRelationships.test.ts @@ -0,0 +1,296 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const files = new Map(); + + const norm = (p: string): string => p.replace(/\\/g, '/'); + + const readFile = vi.fn(async (filePath: string) => { + const data = files.get(norm(filePath)); + if (data === undefined) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return data; + }); + + const atomicWrite = vi.fn(async (filePath: string, data: string) => { + files.set(norm(filePath), data); + }); + + return { files, readFile, atomicWrite, norm }; +}); + +vi.mock('fs', () => ({ + promises: { + readFile: hoisted.readFile, + mkdir: vi.fn(async () => undefined), + access: vi.fn(async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }), + }, + constants: { F_OK: 0 }, +})); + +vi.mock('../../../../src/main/utils/pathDecoder', () => ({ + getTasksBasePath: () => '/mock/tasks', +})); + +vi.mock('../../../../src/main/services/team/atomicWrite', () => ({ + atomicWriteAsync: hoisted.atomicWrite, +})); + +import { TeamTaskWriter } from '../../../../src/main/services/team/TeamTaskWriter'; + +function setTask(teamName: string, id: string, data: Record): void { + const taskPath = `/mock/tasks/${teamName}/${id}.json`; + hoisted.files.set( + taskPath, + JSON.stringify({ id, status: 'pending', blocks: [], blockedBy: [], ...data }) + ); +} + +function getTask(teamName: string, id: string): Record { + const taskPath = `/mock/tasks/${teamName}/${id}.json`; + const raw = hoisted.files.get(taskPath); + if (!raw) throw new Error(`Task ${id} not found in mock files`); + return JSON.parse(raw) as Record; +} + +describe('TeamTaskWriter — addRelationship', () => { + const writer = new TeamTaskWriter(); + const team = 'test-team'; + + beforeEach(() => { + hoisted.files.clear(); + hoisted.readFile.mockClear(); + hoisted.atomicWrite.mockClear(); + }); + + describe('blockedBy', () => { + it('adds blockedBy to task and blocks to target', async () => { + setTask(team, '1', { subject: 'Setup' }); + setTask(team, '2', { subject: 'Build' }); + + await writer.addRelationship(team, '2', '1', 'blockedBy'); + + const task2 = getTask(team, '2'); + const task1 = getTask(team, '1'); + expect(task2.blockedBy).toEqual(['1']); + expect(task1.blocks).toEqual(['2']); + }); + + it('is idempotent — does not duplicate entries', async () => { + setTask(team, '1', { subject: 'Setup' }); + setTask(team, '2', { subject: 'Build', blockedBy: ['1'] }); + setTask(team, '1', { subject: 'Setup', blocks: ['2'] }); + + await writer.addRelationship(team, '2', '1', 'blockedBy'); + + const task2 = getTask(team, '2'); + const task1 = getTask(team, '1'); + expect(task2.blockedBy).toEqual(['1']); + expect(task1.blocks).toEqual(['2']); + }); + }); + + describe('blocks', () => { + it('delegates to reverse blockedBy', async () => { + setTask(team, '1', { subject: 'Setup' }); + setTask(team, '2', { subject: 'Build' }); + + await writer.addRelationship(team, '1', '2', 'blocks'); + + const task1 = getTask(team, '1'); + const task2 = getTask(team, '2'); + expect(task1.blocks).toEqual(['2']); + expect(task2.blockedBy).toEqual(['1']); + }); + }); + + describe('related', () => { + it('adds bidirectional related links', async () => { + setTask(team, '3', { subject: 'Frontend' }); + setTask(team, '4', { subject: 'Backend' }); + + await writer.addRelationship(team, '3', '4', 'related'); + + const task3 = getTask(team, '3'); + const task4 = getTask(team, '4'); + expect(task3.related).toEqual(['4']); + expect(task4.related).toEqual(['3']); + }); + }); + + describe('validation', () => { + it('rejects self-reference', async () => { + setTask(team, '1', { subject: 'Task' }); + + await expect(writer.addRelationship(team, '1', '1', 'blockedBy')).rejects.toThrow( + 'Cannot link a task to itself' + ); + }); + + it('rejects non-existent task', async () => { + setTask(team, '1', { subject: 'Task' }); + + await expect(writer.addRelationship(team, '1', '999', 'blockedBy')).rejects.toThrow( + 'Task not found: 999' + ); + }); + + it('rejects non-existent source task', async () => { + setTask(team, '1', { subject: 'Task' }); + + await expect(writer.addRelationship(team, '999', '1', 'blockedBy')).rejects.toThrow( + 'Task not found: 999' + ); + }); + + it('rejects self-reference via blocks type (delegation preserves check)', async () => { + setTask(team, '1', { subject: 'Task' }); + + await expect(writer.addRelationship(team, '1', '1', 'blocks')).rejects.toThrow( + 'Cannot link a task to itself' + ); + }); + + it('rejects self-reference via related type', async () => { + setTask(team, '1', { subject: 'Task' }); + + await expect(writer.addRelationship(team, '1', '1', 'related')).rejects.toThrow( + 'Cannot link a task to itself' + ); + }); + }); + + describe('circular dependency detection', () => { + it('detects direct cycle: A blocked-by B, then B blocked-by A', async () => { + setTask(team, '1', { subject: 'A', blockedBy: ['2'] }); + setTask(team, '2', { subject: 'B', blocks: ['1'] }); + + await expect(writer.addRelationship(team, '2', '1', 'blockedBy')).rejects.toThrow( + 'Circular dependency' + ); + }); + + it('allows redundant blockedBy (A→B→C, then C blocked-by A is redundant, not circular)', async () => { + // #3 already depends on #1 transitively (3→2→1) + // Adding direct #3 blockedBy #1 is redundant but NOT a cycle + setTask(team, '1', { subject: 'A' }); + setTask(team, '2', { subject: 'B', blockedBy: ['1'] }); + setTask(team, '3', { subject: 'C', blockedBy: ['2'] }); + + // Should succeed — no cycle + await writer.addRelationship(team, '3', '1', 'blockedBy'); + const task3 = getTask(team, '3'); + expect(task3.blockedBy).toContain('1'); + }); + + it('detects transitive cycle when closing the loop', async () => { + // Chain: #3 blockedBy #2, #2 blockedBy #1 + // Trying: #1 blockedBy #3 — would create cycle 1→3→2→1 + setTask(team, '1', { subject: 'A' }); + setTask(team, '2', { subject: 'B', blockedBy: ['1'] }); + setTask(team, '3', { subject: 'C', blockedBy: ['2'] }); + + await expect(writer.addRelationship(team, '1', '3', 'blockedBy')).rejects.toThrow( + 'Circular dependency' + ); + }); + }); +}); + +describe('TeamTaskWriter — removeRelationship', () => { + const writer = new TeamTaskWriter(); + const team = 'test-team'; + + beforeEach(() => { + hoisted.files.clear(); + hoisted.readFile.mockClear(); + hoisted.atomicWrite.mockClear(); + }); + + describe('blockedBy', () => { + it('removes blockedBy from task and blocks from target', async () => { + setTask(team, '1', { subject: 'Setup', blocks: ['2'] }); + setTask(team, '2', { subject: 'Build', blockedBy: ['1'] }); + + await writer.removeRelationship(team, '2', '1', 'blockedBy'); + + const task2 = getTask(team, '2'); + const task1 = getTask(team, '1'); + expect(task2.blockedBy).toEqual([]); + expect(task1.blocks).toEqual([]); + }); + + it('handles missing target gracefully', async () => { + setTask(team, '2', { subject: 'Build', blockedBy: ['1'] }); + + await writer.removeRelationship(team, '2', '1', 'blockedBy'); + + const task2 = getTask(team, '2'); + expect(task2.blockedBy).toEqual([]); + }); + }); + + describe('blocks', () => { + it('delegates to reverse blockedBy removal', async () => { + setTask(team, '1', { subject: 'Setup', blocks: ['2'] }); + setTask(team, '2', { subject: 'Build', blockedBy: ['1'] }); + + await writer.removeRelationship(team, '1', '2', 'blocks'); + + const task1 = getTask(team, '1'); + const task2 = getTask(team, '2'); + expect(task1.blocks).toEqual([]); + expect(task2.blockedBy).toEqual([]); + }); + }); + + describe('related', () => { + it('removes bidirectional related links', async () => { + setTask(team, '3', { subject: 'Frontend', related: ['4'] }); + setTask(team, '4', { subject: 'Backend', related: ['3'] }); + + await writer.removeRelationship(team, '3', '4', 'related'); + + const task3 = getTask(team, '3'); + const task4 = getTask(team, '4'); + expect(task3.related).toEqual([]); + expect(task4.related).toEqual([]); + }); + + it('handles missing target gracefully', async () => { + setTask(team, '3', { subject: 'Frontend', related: ['4'] }); + + await writer.removeRelationship(team, '3', '4', 'related'); + + const task3 = getTask(team, '3'); + expect(task3.related).toEqual([]); + }); + }); + + describe('validation', () => { + it('rejects non-existent source task', async () => { + await expect(writer.removeRelationship(team, '999', '1', 'blockedBy')).rejects.toThrow( + 'Task not found: 999' + ); + }); + + it('is a no-op when removing a relationship that does not exist', async () => { + setTask(team, '1', { subject: 'A', blocks: ['3'] }); + setTask(team, '2', { subject: 'B' }); + + // Task 1 has no blockedBy referencing task 2 — should not throw + await writer.removeRelationship(team, '1', '2', 'blockedBy'); + + const task1 = getTask(team, '1'); + expect(task1.blockedBy).toEqual([]); + expect(task1.blocks).toEqual(['3']); // other relationships preserved + }); + }); +});