feat: implement custom project path management and task relationship features

- Added API endpoints for adding and removing custom project paths, allowing persistence across app restarts.
- Implemented IPC handlers for managing custom project paths in the application.
- Introduced functionality for adding and removing task relationships (blockedBy, blocks, related) between tasks, enhancing task management capabilities.
- Updated relevant components and services to support the new task relationship features and ensure proper integration with the existing task management system.
- Enhanced notifications for task relationships to improve user awareness of task dependencies.
This commit is contained in:
iliya 2026-03-01 20:42:17 +02:00
parent a0764e39d1
commit 4c5f0c3cc2
27 changed files with 1353 additions and 30 deletions

View file

@ -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<ConfigResult> => {
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<ConfigResult> => {
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<ConfigResult<string[]>> => {
return { success: true, data: [] };

View file

@ -126,6 +126,61 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
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<string, unknown> | 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<string, unknown>;
}
} 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<void> {
// 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);

View file

@ -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<IpcResult
}
}
/**
* Handler for 'config:addCustomProjectPath' - Persists a custom project path.
*/
async function handleAddCustomProjectPath(
_event: IpcMainInvokeEvent,
projectPath: string
): Promise<IpcResult> {
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<IpcResult> {
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');

View file

@ -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<IpcResult<void>> {
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<IpcResult<void>> {
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
)
);
}

View file

@ -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));

View file

@ -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
// ===========================================================================

View file

@ -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 <id> <pending|in_progress|completed|deleted> [--team <team>]',
' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--blocked-by 2,3] [--related 5] [--status ...] [--notify --from "member"] [--team <team>]',
' node teamctl.js task link <id> --blocked-by <targetId> [--team <team>]',
' node teamctl.js task link <id> --blocks <targetId> [--team <team>]',
' node teamctl.js task link <id> --related <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --blocked-by <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --blocks <targetId> [--team <team>]',
' node teamctl.js task unlink <id> --related <targetId> [--team <team>]',
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <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 <id> --blocked-by|--blocks|--related <targetId>');
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 <id> --blocked-by|--blocks|--related <targetId>');
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));
}

View file

@ -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<void> {
await this.taskWriter.addRelationship(teamName, taskId, targetId, type);
}
async removeTaskRelationship(
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> {
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
}
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
const comment = await this.taskWriter.addComment(teamName, taskId, text);

View file

@ -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 <taskId> clear --from "<your-name>"
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 <id> <member-name> --notify --from "${leadName}"`,
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
`- Create with deps: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --owner "<member>" --notify --from "${leadName}"`,
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
``,
`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.

View file

@ -92,6 +92,172 @@ export class TeamTaskWriter {
});
}
async addRelationship(
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> {
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<void> {
// 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<string> {
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<void> {
const visited = new Set<string>();
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<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);

View file

@ -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
// =============================================================================

View file

@ -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<void> => {
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
},
addCustomProjectPath: async (projectPath: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_ADD_CUSTOM_PROJECT_PATH, projectPath);
},
removeCustomProjectPath: async (projectPath: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_REMOVE_CUSTOM_PROJECT_PATH, projectPath);
},
},
// Deep link navigation
@ -759,6 +769,34 @@ const electronAPI: ElectronAPI = {
showMessageNotification: async (data: TeamMessageNotificationData) => {
return invokeIpcWithResult<void>(TEAM_SHOW_MESSAGE_NOTIFICATION, data);
},
addTaskRelationship: async (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => {
return invokeIpcWithResult<void>(
TEAM_ADD_TASK_RELATIONSHIP,
teamName,
taskId,
targetId,
type
);
},
removeTaskRelationship: async (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => {
return invokeIpcWithResult<void>(
TEAM_REMOVE_TASK_RELATIONSHIP,
teamName,
taskId,
targetId,
type
);
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,

View file

@ -481,6 +481,10 @@ export class HttpAPIClient implements ElectronAPI {
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
addCustomProjectPath: (projectPath: string): Promise<void> =>
this.post('/api/config/add-custom-project-path', { projectPath }),
removeCustomProjectPath: (projectPath: string): Promise<void> =>
this.post('/api/config/remove-custom-project-path', { projectPath }),
};
// ---------------------------------------------------------------------------
@ -808,6 +812,22 @@ export class HttpAPIClient implements ElectronAPI {
showMessageNotification: async (): Promise<void> => {
// Not available via HTTP client — native notifications require Electron
},
addTaskRelationship: async (
_teamName: string,
_taskId: string,
_targetId: string,
_type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> => {
throw new Error('Task relationships are not available in browser mode');
},
removeTaskRelationship: async (
_teamName: string,
_taskId: string,
_targetId: string,
_type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> => {
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)

View file

@ -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();

View file

@ -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}
</button>
);
@ -67,7 +70,7 @@ export const MemberBadge = ({
return (
<span className="inline-flex items-center gap-1">
{avatar}
{!hideAvatar && avatar}
{badge}
</span>
);

View file

@ -317,7 +317,12 @@ export const ActivityItem = ({
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
&rarr;
</span>
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
<MemberBadge
name={message.to}
color={recipientColor}
hideAvatar={message.to === 'user'}
onClick={onMemberNameClick}
/>
</>
) : null}

View file

@ -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 (
<div ref={ref} className="min-h-px">
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
<ActivityItem
message={message}
teamName={teamName}
@ -113,6 +115,11 @@ export const ActivityTimeline = ({
}: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
// --- New-message animation tracking ---
const knownKeysRef = useRef<Set<string>>(new Set<string>());
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<string>();
}
// 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<string>();
}
// Normal update: unknown keys are new messages
const newKeys = new Set<string>();
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}

View file

@ -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}

View file

@ -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,

View file

@ -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<HTMLDivElement>;
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
@ -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 &&

View file

@ -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 (
<div className="overflow-auto">
{isMissingOnDisk && (
<div className="border-b border-border bg-red-500/10 px-4 py-2 text-xs text-red-200">
File is missing on disk. This diff may be only a preview from agent logs. Use{' '}
<span className="font-medium text-red-100">Restore</span> to create the file on disk.
</div>
)}
<DiffErrorBoundary
filePath={file.filePath}
oldString={originalForDiff}
@ -125,7 +132,7 @@ export const FileSectionDiff = ({
modified={resolvedModified}
fileName={file.relativePath}
readOnly={false}
showMergeControls={true}
showMergeControls={!isMissingOnDisk}
collapseUnchanged={collapseUnchanged}
usePortionCollapse={true}
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}

View file

@ -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<string, string> = {
'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 && (
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={[
'rounded px-1.5 py-0.5 text-[10px]',
fileContent.contentSource === 'unavailable'
? 'bg-red-500/20 text-red-300'
: 'bg-surface-raised text-text-muted',
].join(' ')}
>
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
{fileContent.contentSource === 'unavailable' ? (
<div className="space-y-1">
<div className="font-medium text-text">File is missing on disk</div>
<div className="text-text-muted">
We can still show a preview from agent logs, but your filesystem is out of sync.
</div>
{restoreContent !== null ? (
<div className="text-text-muted">
Use <span className="font-medium text-text">Restore</span> to write the preview
content back to disk.
</div>
) : (
<div className="text-text-muted">
Full file content is not available to restore automatically.
</div>
)}
</div>
) : (
<span>
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
</span>
)}
</TooltipContent>
</Tooltip>
)}
{fileDecision && (
@ -90,6 +140,23 @@ export const FileSectionHeader = ({
)}
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
{canRestore && restoreContent !== null && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onRestoreMissingFile?.(file.filePath, restoreContent)}
disabled={applying}
className="flex items-center gap-1 rounded bg-blue-500/15 px-2 py-1 text-xs text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
<FilePlus className="size-3" />
Restore
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Create/restore this file on disk from the preview
</TooltipContent>
</Tooltip>
)}
{hasEdits && (
<>
<Tooltip>

View file

@ -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;

View file

@ -175,6 +175,18 @@ export interface TeamSlice {
memberName: string,
role: string | undefined
) => Promise<void>;
addTaskRelationship: (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => Promise<void>;
removeTaskRelationship: (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => Promise<void>;
setTaskNeedsClarification: (
teamName: string,
taskId: string,
@ -612,6 +624,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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)

View file

@ -184,6 +184,10 @@ export interface ConfigAPI {
hideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
/** Bulk unhide sessions for a project */
unhideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
/** Add a custom project path (persisted across restarts) */
addCustomProjectPath: (projectPath: string) => Promise<void>;
/** Remove a custom project path */
removeCustomProjectPath: (projectPath: string) => Promise<void>;
}
export interface ClaudeRootInfo {
@ -449,6 +453,18 @@ export interface TeamsAPI {
restoreTask: (teamName: string, taskId: string) => Promise<void>;
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
showMessageNotification: (data: TeamMessageNotificationData) => Promise<void>;
addTaskRelationship: (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => Promise<void>;
removeTaskRelationship: (
teamName: string,
taskId: string,
targetId: string,
type: 'blockedBy' | 'blocks' | 'related'
) => Promise<void>;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void

View file

@ -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);
});
});
});

View file

@ -0,0 +1,296 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
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<string, unknown>): 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<string, unknown> {
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<string, unknown>;
}
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
});
});
});